Skip to main content

Declarative Flow for Database-per-Tenant Architectures

The previous sections deployed schema changes to tenant fleets with versioned migrations. This section shows the declarative alternative: define the desired schema once, and let atlas schema apply converge every tenant database in the target group to it. There are no migration files to maintain; each tenant is inspected and migrated independently, and tenants that are already in the desired state are left untouched.

Setting Up

We will reuse the setup from the deploying section: SQLite files as target databases and a statically defined target group. The only difference is that the environment points to a desired schema state (schema.src) instead of a migration directory, and sets a dev database for planning:

atlas.hcl
locals {
tenant = ["tenant_1", "tenant_2"]
}

env "prod" {
for_each = toset(local.tenant)
url = "sqlite://${each.value}.db"
dev = "sqlite://dev?mode=memory"
schema {
src = "file://schema.sql"
}
}

The desired state of every tenant database is a single schema file:

schema.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);

Running atlas schema apply --env prod expands the environment to one instance per tenant and converges each database in turn:

atlas schema apply --env prod --auto-approve
Planning migration statements (1 in total):

-- create "users" table:
-> CREATE TABLE `users` (
`id` integer NULL,
`name` text NOT NULL,
PRIMARY KEY (`id`)
);

-------------------------------------------

Applying approved migration (1 statement in total):

-- create "users" table
-> CREATE TABLE `users` (
`id` integer NULL,
`name` text NOT NULL,
PRIMARY KEY (`id`)
);
-- ok (152.083µs)

-------------------------
-- 188.25µs
-- 1 migration
-- 1 sql statement

The same report is printed for tenant_2. Re-running the command shows that the fleet converged; each tenant is inspected and reports that there is nothing to do:

Schema is synced, no changes to be made
Schema is synced, no changes to be made

Rolling Out a Change

To change the fleet's schema, edit the desired state. Here we add an email column:

schema.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL DEFAULT 'unknown'
);

Running atlas schema apply --env prod again plans and applies the same change to each tenant:

Planning migration statements (1 in total):

-- add column "email" to table: "users":
-> ALTER TABLE `users` ADD COLUMN `email` text NOT NULL DEFAULT 'unknown';

-------------------------------------------

Applying approved migration (1 statement in total):

-- add column "email" to table: "users"
-> ALTER TABLE `users` ADD COLUMN `email` text NOT NULL DEFAULT 'unknown';
-- ok (183.625µs)

-------------------------
-- 196.375µs
-- 1 migration
-- 1 sql statement

Partial rollouts and re-runs

Because every tenant converges independently, an interrupted rollout needs no special recovery: re-running the same command is the retry. Suppose the change above reached tenant_1 but the process stopped before tenant_2. The next run skips the migrated tenant and brings the lagging one up to date:

Schema is synced, no changes to be made
Planning migration statements (1 in total):

-- add column "email" to table: "users":
-> ALTER TABLE `users` ADD COLUMN `email` text NOT NULL DEFAULT 'unknown';

-------------------------------------------

Applying approved migration (1 statement in total):

-- add column "email" to table: "users"
-> ALTER TABLE `users` ADD COLUMN `email` text NOT NULL DEFAULT 'unknown';
-- ok (102.333µs)

-------------------------
-- 115.25µs
-- 1 migration
-- 1 sql statement

Pre-Planning for Production

The flow above uses --auto-approve, which is fine for development but not recommended for production fleets. For production, pre-plan the migration with atlas schema plan: the plan is reviewed and approved once, and atlas schema apply then executes it on every tenant without recalculating SQL at runtime or prompting per tenant.

The atlas schema plan command is available exclusively to Pro users. To use this feature, run:

atlas login

Plans are stored in the Atlas Registry and matched by schema state transition, not by database URL. A plan records fingerprints of the states it transitions between, so a single approved plan (S1 → S2) covers every tenant database that is in state S1, and each tenant receives exactly the SQL that was reviewed. To connect the project to a registry repository, add a repo block to the schema configuration:

atlas.hcl
env "prod" {
for_each = toset(local.tenant)
url = "sqlite://${each.value}.db"
dev = "sqlite://dev?mode=memory"
schema {
src = "file://schema.sql"
repo {
name = "app"
}
}
}

The planning workflow itself, generating the plan, reviewing its analysis report, editing it if needed, and approving it manually or through the schema/plan CI action, is covered step by step in Pre-planning Schema Migrations. Once a plan is approved, applying to the fleet reports each tenant as migrated by the pre-planned file:

Applying approved migration using pre-planned file 20240923085308 (1 statement in total):

-- add column "email" to table: "users"
-> ALTER TABLE `users` ADD COLUMN `email` text NOT NULL DEFAULT 'unknown';
-- ok (749.815µs)

-------------------------
-- 802.902µs
-- 1 migration
-- 1 sql statement

A tenant whose state matches neither side of the approved transition (for example, after a manual change) is never migrated silently under the existing approval. Atlas plans a new migration for that tenant, lints it, and decides based on the review policy defined in the atlas.hcl file:

atlas.hcl
env "prod" {
for_each = toset(local.tenant)
url = "sqlite://${each.value}.db"
dev = "sqlite://dev?mode=memory"
lint {
review = WARNING // ERROR | WARNING | ALWAYS
}
schema {
src = "file://schema.sql"
repo {
name = "app"
}
}
}

With review = WARNING, a diverged tenant is migrated automatically only when its linting report is completely clean: anything Atlas auto-applied is guaranteed to carry no warnings, which is the case for purely additive changes. If the report contains warnings or errors, such as destructive changes, Atlas switches to ad-hoc approval: it creates a plan for that tenant's transition, prints a link to review it in the Atlas Registry, and blocks the migration until the plan is approved. Set review = ERROR to gate only on errors and let warning-level changes through. See Applying to Multiple Databases with Shared Schema for the full matching flow.

Staged Rollouts

The rollout strategies from the previous section work with the declarative flow as well. Attach a rollout block to the for_each environment to apply changes to canary tenants first, then to the rest of the fleet with controlled parallelism and error handling. See Deployment Rollout Strategies for a complete declarative example.

Next Steps

Deploying declaratively to a fleet gives per-tenant convergence, but visibility into each tenant's status is still per database. The next section shows how the Atlas Cloud control plane tracks the state of the entire fleet in one place.