ADR-021 — Migrations are owned by the migrator
Status: Accepted; mechanics superseded by ADR-026 (2026-10-26:
plain SQL migrations, migrate.sh down <version> instead of MIGRATE_TARGET, no Api migration under
dotnet run). Who migrates and the rollback order still hold. · Date: 2026-10-07 · PRs: #706 (Api stops migrating), this PR (readiness,
rollback path) · Review: 2026-10 #17, backend N6
Context
Two things applied migrations: the one-shot migrator compose service (migrate.sh, dotnet ef database update) and db.Database.Migrate() in Api/Program.cs. With the schema at head the second
was a no-op, so nobody noticed. It only mattered on a rollback: MIGRATE_TARGET=<name> rolls the
schema back, and the next Api restart migrated it forward again.
Checking that in a scratch stack found a second, older defect: the documented rollback
(MIGRATE_TARGET=0 docker compose up migrator) never reached the container. #12 (2026-01-23) dropped
MIGRATE_TARGET from the migrator’s environment:, so compose ignored the shell variable and the
migrator logged Target: latest. Rollback had been a no-op for nine months.
Decision
- Only the migrator migrates. Compose already orders it:
apiandworkerwait onmigrator: service_completed_successfully, and every deploy (docker compose up -d) re-runs it. The Worker and mcp-server never migrated. - The Api migrates only for
dotnet runwithout Docker:ASPNETCORE_ENVIRONMENT=DevelopmentandDatabase:MigrateOnStartup=true, which onlylaunchSettings.jsonsets (Api/Extensions/MigrationPolicy.cs). Notappsettings.Development.json: CI runs the compose stack as Development, and must prove the migrator, not the Api. - A schema behind the build is reported, not fatal. At start the Api logs Critical (reaches
Sentry) with the pending count and newest name, and
/health/readyreportscomponents.schema = { status: "behind", pending: [...] }with 503 until the migrator catches up. It re-checks per request, so it recovers without a restart./health(liveness, compose healthcheck, deploy gate) stays “DB reachable”. - Rollback is a one-off run with
-e:docker compose run --rm -e MIGRATE_TARGET=<name> migrator. Not anenvironment:entry: compose also reads.env, and aMIGRATE_TARGETleft there would roll production back on every deploy (0drops every table). - The migrator refuses a rollback it cannot do. EF reverts only migrations its own assembly
contains, so an older image asked for a target reverted nothing and exited 0.
migrate.shnow reads__EFMigrationsHistory(psql) first: withMIGRATE_TARGETset and applied migrations this image does not contain, it exits 1 naming the newest. Without a target it prints a note and continues — that is the normal state after a code rollback. A target name the image does not have at all already fails in EF (“migration not found”, exit 1).
Rolling back a release with a migration — order matters
- Schema first, with the image deployed now (it has the
Down()methods):docker compose run --rm -e MIGRATE_TARGET=<last migration of the target release> migrator. - Verify:
/api/health/readyshowsschema: behindwith exactly the migrations you meant to remove inpending. The running Api now logs Critical andhealth-check.ymlalarms — expected for the minutes until step 3. - Then the code: deploy
rollback_commit. Its deploy re-tagstextstack-migrator:latestto the old image and runs it with no target: nothing is pending for that image and nothing unknown is applied, so it is a no-op; the old Api then reportsschema: ok.
The reverse order cannot work: after step 3 the only migrator on the box is the old one, which does not contain the newer migrations. Before step 5 it silently did nothing; now it exits 1.
Alternatives
| option | verdict | why |
|---|---|---|
| Throw at start on pending migrations | rejected | restart: always turns it into a crash-loop through a deliberate rollback — the site goes fully down instead of partly working. Compose ordering already prevents the normal case. |
Fail /health instead of /health/ready |
rejected | compose marks the Api unhealthy and the deploy gate fails on an operator’s intended state; liveness is not schema. |
Restore MIGRATE_TARGET: ${MIGRATE_TARGET:-} in compose |
rejected | the .env hazard above. |
| Keep the Api migrating, guard rollback some other way | rejected | two owners of one schema is the bug. |
Consequences
- After a schema rollback, restart the Api with
docker compose restart apiorup -d --no-deps …. A plaindocker compose up -d— and any deploy of the current code — re-runs the migrator to head. So follow a schema rollback with the code rollback (order above), or the next deploy re-applies it. - A code rollback over a newer schema (DB ahead of the build) is not flagged: additive migrations make that the normal state of a code rollback.
health-check.ymlalarms within 5 min if production serves against a schema behind its build: step “Schema matches the build” fails with::error::naming the pending migrations. It reads the one/health/readyresponse fetched by “Readiness probe” (no-f, so the 503 body is kept), which the SSG step reuses; each alarm carries its own label.- The migrator image is an EF Core migrations bundle on
runtime-deps(2026-10-07), not the SDK: the migrations this image contains come from/app/known-migrations, written at build time bydotnet ef migrations list --no-connect; step 5’s check and the pending list compare it with__EFMigrationsHistory(psql). Same rollback command, same exits. - Local
dotnet runis unchanged.dotnet run --no-launch-profileskips the flag: rundotnet ef database update --project backend/src/Infrastructure --startup-project backend/src/Api.