Accepted, supersedes 0005Decided Replaces ADR 0005: Migrations are never auto-applied on app boot
ADR 0011: Migrations run at app-container boot
ADR 0005 said the project would never auto-apply migrations at app boot - that migrations were an explicit operator action, and that a one-shot migrate sidecar service in docker compose would carry the explicitness…
On this page
- Status: Accepted. Supersedes ADR 0005.
- Date: 2026-05-18
- Deciders: @jseifeddine
Context#
ADR 0005 said the project would never auto-apply migrations at app boot - that migrations were
an explicit operator action, and that a one-shot migrate sidecar service in
docker compose would carry the explicitness intent over to the container world.
In practice, the sidecar shape has costs that outweigh the explicitness gain for the deployment shapes this project actually targets:
- Two-deploy mental model. Every operator iterating on the stack has to
remember the order: pull, run
migrate, only then startapp. With one container instead of two, the boot order is a fact of the entrypoint, not a fact the operator has to know. - SQLite has no notion of a separate sidecar writer. The SQLite variant added in this commit owns a single file on a shared volume. A "migrate container" attached to the same volume would still be SQLite's only writer - the sidecar shape is theatre, not enforcement.
- The "explicit operator action" frame leaked. Operators bringing up the
stack on a new host either ran
docker compose upand got migrations for free (sidecar dependency onservice_healthy), or they randocker compose run --rm migrateand were surprised that it didn't "do" anything visible. Either way the explicitness was implicit.
What we still want from ADR 0005:
- Single-writer safety. When the app scales to multiple replicas, only one of them should actually apply pending migrations on cold start.
- Operator opt-out for fancy CI/CD flows. Some operators want to run migrations from CI before pods are even allowed to come up - the new shape needs a switch for that.
Decision#
Migrations run inside the app container's entrypoint, before the Next.js server starts. Both compose files use the same image; there is no migrate sidecar.
- The entrypoint is
docker/entrypoint.mjs. It spawnsnode scripts/migrate.js, then dynamic-imports the Next.js standalone server. Exit code from migrate aborts the boot - a broken migration is a refused start, not a degraded run. scripts/migrate.ts(compiled toscripts/migrate.jsat build time) inspectsDATABASE_URL:postgres://.../postgresql://...→ Drizzle's pg migrator over thedrizzle/folder, wrapped in apg_advisory_lockso multi-pod boot serializes on one writer.file:.../sqlite:...→ Drizzle's better-sqlite3 migrator over thedrizzle-sqlite/folder. No lock needed; SQLite is single-writer by construction.
MIGRATE_ON_BOOT=falseopts out for the out-of-band-migrations workflow. The entrypoint logs the skip and proceeds to the server boot.
Consequences#
Positive:
- One container, one entrypoint. Operators bring the stack up with
docker compose upand the rest is mechanical. - The SQLite variant works the same as Postgres - one shared compose shape, one mental model.
- The Postgres advisory lock keeps multi-replica deployments safe; the failure mode for the few-second lock-wait window is "the second pod boots a beat later," not a corrupted migration.
Negative:
- A migration error now appears as a failed-boot loop instead of a
separate-step error. Operators have to read the app container logs
to triage. Compose's "container keeps restarting" surface makes this
visible; we'll add a
/readyzcheck that reports migration failure if it becomes a common confusion. - The runner image carries
drizzle-orm,pg, andbetter-sqlite3for the migrate step even though only one driver runs at any deploy. The cost is ~30MB of node_modules, which is below the noise floor on the base image size. - We moved the runner image off distroless (
gcr.io/distroless/nodejs22) tonode:22-bookworm-slim. The distroless tracing didn't pull in the modulesscripts/migrate.jsneeds, so the runner now ships a more conventional Node image. Documented in the Dockerfile header.
Notes for operators#
- Production multi-replica: the advisory lock means concurrent boots
are safe but serial. If your cold-start fleet is large enough that the
serial wait matters, set
MIGRATE_ON_BOOT=falsein the deployment env and runnpm run db:migrateas a pre-deploy step in CI/CD instead. - Single-instance SQLite: zero config; the entrypoint Just Works. Migrations run on first boot and on every subsequent restart (no-op when there's nothing pending).