Chapter 22 · Production Capstone: Design, Cluster, Secure, Tune, and Recover MariaDB

Implement Schema, Indexes, Security, Application Access, and Migration Automation

Build the ServiceHub capstone schema with integrity constraints, workload-derived indexes, least privilege, TLS-aware application access, reproducible migrations, configuration inventory, and automated acceptance checks.

Advanced capstone220–275 minutesschema, security and migration automation labMariaDB Community 12.3.2 current GA referenceCurriculum anchor: MariaDB 11.8 LTS · verify source/target/tool/topology versionsFree local tooling · Last reviewed: August 2026

Learning outcomes

The architecture record is still only a claim. Now ServiceHub needs a schema that makes tenant isolation and business invariants explicit, indexes that match the named journeys, a runtime identity that cannot perform migrations, and a migration process that can prove what version of the database is deployed.

01

Implement the capstone schema with tenant-aware foreign keys, idempotency and outbox integrity.

02

Derive indexes from queue/customer/comment access paths and verify them with EXPLAIN rather than index folklore.

03

Separate runtime, migration and observation privileges with MariaDB roles and inspect effective grants.

04

Define a TLS-aware official MariaDB Connector/Node.js contract using environment-held secrets and explicit session initialization.

05

Create versioned migrations, schema metadata and automated checks for SQL mode, charset/collation, grants and critical indexes.

Privilege scope

The lab uses administrative SQL to create roles/users. In production, account provisioning should be a controlled administrative path separate from normal application deployment. Never place real passwords in repository SQL files; the literal lab secrets below are disposable placeholders only.

1. Create the capstone schema from invariants

sql · 001_capstone_schema.sql
DROP DATABASE IF EXISTS servicehub22;CREATE DATABASE servicehub22  CHARACTER SET utf8mb4  COLLATE utf8mb4_unicode_ci;USE servicehub22;CREATE TABLE tenant (  tenant_id BIGINT PRIMARY KEY,  tenant_key VARCHAR(64) NOT NULL UNIQUE,  display_name VARCHAR(200) NOT NULL,  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)) ENGINE=InnoDB;CREATE TABLE customer (  tenant_id BIGINT NOT NULL,  customer_id BIGINT NOT NULL,  external_ref VARCHAR(128) NOT NULL,  email VARCHAR(320) NULL,  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  PRIMARY KEY (tenant_id, customer_id),  UNIQUE KEY uq_customer_external (tenant_id, external_ref),  CONSTRAINT fk_customer_tenant FOREIGN KEY (tenant_id)    REFERENCES tenant(tenant_id)) ENGINE=InnoDB;CREATE TABLE ticket (  tenant_id BIGINT NOT NULL,  ticket_id BIGINT NOT NULL,  customer_id BIGINT NOT NULL,  status ENUM('OPEN','PENDING','RESOLVED','CLOSED') NOT NULL,  priority TINYINT NOT NULL,  subject VARCHAR(300) NOT NULL,  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)    ON UPDATE CURRENT_TIMESTAMP(6),  PRIMARY KEY (tenant_id, ticket_id),  CONSTRAINT chk_ticket_priority CHECK (priority BETWEEN 1 AND 5),  CONSTRAINT fk_ticket_customer FOREIGN KEY (tenant_id, customer_id)    REFERENCES customer(tenant_id, customer_id)) ENGINE=InnoDB;CREATE TABLE ticket_comment (  tenant_id BIGINT NOT NULL,  ticket_id BIGINT NOT NULL,  comment_id BIGINT NOT NULL,  author_ref VARCHAR(128) NOT NULL,  body TEXT NOT NULL,  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  PRIMARY KEY (tenant_id, ticket_id, comment_id),  CONSTRAINT fk_comment_ticket FOREIGN KEY (tenant_id, ticket_id)    REFERENCES ticket(tenant_id, ticket_id) ON DELETE CASCADE) ENGINE=InnoDB;CREATE TABLE request_dedup (  tenant_id BIGINT NOT NULL,  idempotency_key VARCHAR(128) NOT NULL,  operation_name VARCHAR(80) NOT NULL,  result_ticket_id BIGINT NULL,  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  PRIMARY KEY (tenant_id, idempotency_key)) ENGINE=InnoDB;CREATE TABLE outbox_event (  event_id BIGINT AUTO_INCREMENT PRIMARY KEY,  tenant_id BIGINT NOT NULL,  aggregate_type VARCHAR(40) NOT NULL,  aggregate_id BIGINT NOT NULL,  event_type VARCHAR(80) NOT NULL,  payload LONGTEXT NOT NULL,  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  published_at DATETIME(6) NULL) ENGINE=InnoDB;CREATE TABLE schema_migration (  version VARCHAR(32) PRIMARY KEY,  description VARCHAR(255) NOT NULL,  checksum_sha256 CHAR(64) NOT NULL,  applied_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  applied_by VARCHAR(128) NOT NULL) ENGINE=InnoDB;

Composite foreign keys deliberately carry tenant_id. That makes cross-tenant references structurally invalid instead of relying only on application filters. The pattern is not free—keys are wider—but it turns the most important isolation invariant into database-enforced correctness.

2. Add only indexes tied to named access paths

sql · 002_workload_indexes.sql
USE servicehub22;ALTER TABLE ticket  ADD KEY ix_ticket_queue (tenant_id, status, updated_at, ticket_id),  ADD KEY ix_ticket_customer (tenant_id, customer_id, created_at, ticket_id);ALTER TABLE ticket_comment  ADD KEY ix_comment_recent (tenant_id, ticket_id, created_at, comment_id);ALTER TABLE outbox_event  ADD KEY ix_outbox_unpublished (published_at, event_id);SHOW INDEX FROM ticket;SHOW INDEX FROM ticket_comment;SHOW INDEX FROM outbox_event;

The queue index starts with equality predicates (tenant_id,status) and then the ordering/range columns. The outbox index supports scanning unpublished rows. Neither index is “good” merely because it exists: Lesson 3 proves plan and runtime behavior with a representative dataset.

3. Seed deterministic data and verify relational invariants

sql · seed/reset fixture
USE servicehub22;INSERT INTO tenant VALUES(1,'acme','Acme Support',NOW(6)),(2,'globex','Globex Support',NOW(6));INSERT INTO customer(tenant_id,customer_id,external_ref,email) VALUES(1,1001,'ACME-C-1','alice@example.invalid'),(1,1002,'ACME-C-2','bob@example.invalid'),(2,2001,'GLOBEX-C-1','carol@example.invalid');INSERT INTO ticket(tenant_id,ticket_id,customer_id,status,priority,subject) VALUES(1,5001,1001,'OPEN',2,'Login problem'),(1,5002,1002,'PENDING',3,'Invoice question'),(2,9001,2001,'OPEN',1,'API unavailable');INSERT INTO ticket_comment VALUES(1,5001,1,'agent-7','Initial investigation',NOW(6));-- Deliberately wrong: customer 2001 belongs to tenant 2.-- This must fail with a foreign-key error.INSERT INTO ticket(tenant_id,ticket_id,customer_id,status,priority,subject)VALUES (1,5999,2001,'OPEN',2,'cross-tenant bug');

The failure is a successful test: the database rejected a cross-tenant reference. Remove the deliberately failing statement from automated seed scripts after proving the invariant, or place it in a negative-test file whose expected error is asserted.

4. Least privilege: roles describe capabilities, users identify clients

sql · disposable roles and users
CREATE ROLE IF NOT EXISTS cap_runtime;CREATE ROLE IF NOT EXISTS cap_migrator;CREATE ROLE IF NOT EXISTS cap_observer;GRANT SELECT, INSERT, UPDATE, DELETE ON servicehub22.* TO cap_runtime;GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP,      REFERENCES, TRIGGER ON servicehub22.* TO cap_migrator;GRANT SELECT ON servicehub22.* TO cap_observer;CREATE USER IF NOT EXISTS 'cap_app'@'localhost' IDENTIFIED BY 'DISPOSABLE-app-secret';CREATE USER IF NOT EXISTS 'cap_migrate'@'localhost' IDENTIFIED BY 'DISPOSABLE-migrate-secret';CREATE USER IF NOT EXISTS 'cap_observe'@'localhost' IDENTIFIED BY 'DISPOSABLE-observe-secret';GRANT cap_runtime TO 'cap_app'@'localhost';GRANT cap_migrator TO 'cap_migrate'@'localhost';GRANT cap_observer TO 'cap_observe'@'localhost';SET DEFAULT ROLE cap_runtime FOR 'cap_app'@'localhost';SET DEFAULT ROLE cap_migrator FOR 'cap_migrate'@'localhost';SET DEFAULT ROLE cap_observer FOR 'cap_observe'@'localhost';SHOW GRANTS FOR 'cap_app'@'localhost';SHOW GRANTS FOR cap_runtime;

The runtime identity intentionally lacks ALTER, DROP and user-management privileges. A compromised web process should not automatically become a schema administrator. If your deployment tool needs broader privileges, give them to a separate migration identity for the shortest required window.

5. TLS and the application session contract

sql · prove transport capability and the current session
SHOW VARIABLES WHERE Variable_name IN ('have_ssl','require_secure_transport','character_set_server','collation_server','sql_mode','time_zone');SHOW SESSION STATUS LIKE 'Ssl_version';SHOW SESSION STATUS LIKE 'Ssl_cipher';

An empty Ssl_version means this session is not using TLS; a local Unix socket may still be an accepted secure transport when require_secure_transport is enabled. Production TCP clients should validate the server certificate/hostname using a trusted CA rather than merely request encryption.

javascript · official MariaDB Connector/Node.js pool contract
import mariadb from 'mariadb';import fs from 'node:fs';const pool = mariadb.createPool({  host: process.env.DB_HOST,  port: Number(process.env.DB_PORT ?? 3306),  database: 'servicehub22',  user: process.env.DB_USER,  password: process.env.DB_PASSWORD,  connectionLimit: Number(process.env.DB_POOL_SIZE ?? 10),  connectTimeout: 5000,  acquireTimeout: 5000,  charset: 'utf8mb4',  // Production TCP example: provide a CA and verify the certificate.  ssl: process.env.DB_CA_FILE ? { ca: fs.readFileSync(process.env.DB_CA_FILE) } : undefined,  resetAfterUse: true});export async function withDb(fn) {  let conn;  try {    conn = await pool.getConnection();    await conn.query("SET SESSION time_zone = '+00:00'");    await conn.query("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'");    return await fn(conn);  } finally {    if (conn) conn.release();  }}

Pool reset is helpful but not a substitute for a documented session contract. Time zone, SQL mode, charset/collation assumptions and transaction cleanup must be explicit because pooled connections outlive individual requests.

6. Migration automation: make schema state queryable

text · repository layout
db/  migrations/    V001__capstone_schema.sql    V002__workload_indexes.sql    V003__future_additive_change.sql  checks/    verify_contract.sql  seed/    seed_small.sql  reset/    reset_capstone.sqlRules:  migration files are immutable after deployment  calculate SHA-256 before execution  insert version/checksum/applied_by only after success  fail deployment if an already-applied version has a different checksum  destructive contract changes require a compatibility window and rollback boundary
sql · record one migration
-- Compute the file hash outside MariaDB, then let the migration runner pass that exact-- 64-hex value as a bound parameter when it records the successful migration.SELECT version,description,checksum_sha256,applied_at,applied_byFROM servicehub22.schema_migrationORDER BY applied_at;

The placeholder checksum is deliberate: compute it from the exact file on your platform. Never publish a fabricated checksum as evidence.

7. Automated contract checks

sql · checks/verify_contract.sql
-- Server/session contractSELECT @@version AS version,       @@sql_mode AS sql_mode,       @@character_set_server AS server_charset,       @@collation_server AS server_collation,       @@time_zone AS session_time_zone;-- Required tablesSELECT table_name, engine, table_collationFROM information_schema.TABLESWHERE table_schema='servicehub22'ORDER BY table_name;-- Critical indexesSELECT table_name,index_name,       GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns_in_orderFROM information_schema.STATISTICSWHERE table_schema='servicehub22'  AND index_name IN ('ix_ticket_queue','ix_ticket_customer','ix_comment_recent','ix_outbox_unpublished')GROUP BY table_name,index_nameORDER BY table_name,index_name;-- Runtime user should not own DDL privileges.SHOW GRANTS FOR 'cap_app'@'localhost';-- Foreign-key contractSELECT table_name,constraint_name,referenced_table_nameFROM information_schema.REFERENTIAL_CONSTRAINTSWHERE constraint_schema='servicehub22'ORDER BY table_name,constraint_name;

Check your reasoning

  1. Why carry tenant_id in composite foreign keys?
  2. Why can a runtime role have CRUD but not ALTER/DROP?
  3. Does enabling TLS prove peer identity?
  4. Why store migration checksums?
  5. Why verify index column order?
Review the answers
  1. It lets MariaDB reject cross-tenant references structurally, reducing reliance on every application query remembering the tenant predicate.

  2. Application data access and schema administration are distinct capabilities; separating them limits blast radius and supports controlled migrations.

  3. No. The client must validate a trusted certificate/hostname. Encryption without verification can still connect to the wrong peer.

  4. They detect mutation of a version that was already deployed, making schema provenance auditable and repeatable.

  5. Composite-index semantics depend on order; merely checking that an index name exists can hide an incompatible definition.

8. Wrong approach: grant ALL and let the ORM own the schema

A common shortcut is one account with ALL PRIVILEGES, automatic ORM schema synchronization, and no migration ledger. It works until an application bug drops a column, a deployment races another migration, or a compromised process becomes a database administrator. The repair is the explicit capability split above plus immutable, reviewed migrations and post-deploy checks.

Production judgment and bridge to Lesson 3

The schema is now enforceable and deployable, but “correct” is not the same as “fast.” Lesson 3 generates a repeatable workload, captures plans and server counters, and makes one change at a time so performance claims are evidence rather than configuration folklore.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.