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:
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:
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:
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:
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:
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.
Pre-planning Schema Migrations
Plan, review, edit, and approve migrations with atlas schema plan
Declarative Schema Migrations
The schema apply command, review policies, and plan matching
GitHub Actions Ad-hoc Approval
Set up ad-hoc approvals in GitHub Actions workflows
Kubernetes Ad-hoc Approval
Set up ad-hoc approvals with the Atlas Kubernetes Operator