ADR-032 — Migrations & Rollback Strategy
- Status: Accepted
- Date: 2026-05-26
- Related: ADR-019 (Deployment infrastructure), ADR-031 (Error tracking), Issue #493
Context
Bike Trip Planner is moving to production on Oracle Cloud Always Free (ARM A1) orchestrated by Coolify, with deployments triggered by the .github/workflows/deploy.yml workflow on every push to main and on v* tags.
In this setup we need a clear policy answering two questions:
- When and how are Doctrine migrations executed relative to a deployment? The image must come up with a database schema compatible with the code it contains, otherwise the very first request after a rolling restart 500s.
- How do we roll back when a release is bad?
Without an explicit policy:
- Forgetting to run migrations leaves prod with a stale schema → 500s.
- Running migrations manually from a developer laptop creates drift between environments and a long mean-time-to-recover.
- Destructive migrations (
DROP COLUMN,DROP TABLE, type changes) coupled to code changes in a single release make rollback impossible — restoring the previous image cannot restore dropped data.
Decision
Migrations are executed at container boot
A dedicated production entrypoint .docker/php/entrypoint.sh is installed in the frankenphp_prod Dockerfile stage. On container start it runs:
bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
…before exec'ing the underlying docker-php-entrypoint and the FrankenPHP CMD.
Execution is gated by the environment variable MIGRATIONS_ON_BOOT (default: true in compose.yaml and in the Dockerfile ENV). Setting it to false skips auto-migration, useful when:
- A migration is suspected to be problematic and we want to deploy code without touching the schema.
- We want to run a long migration manually in a controlled shell session.
The Coolify healthcheck (/api/healthz, ADR-031 / #486) only goes green once the entrypoint has finished — so a failing migration will keep the container unhealthy and Coolify keeps routing traffic to the previous instance.
Destructive migrations are forbidden in a single release
A migration that drops, renames or narrows the type of a column/table is allowed only after the previous release stopped writing to it. The mandatory pattern is two releases:
- Release N — additive: add the new column/table, dual-write or backfill, keep reading from the old one. No
DROP. - Release N+1 — read switch: read from the new column, write only to the new column. Old column still present.
- Release N+2 — destructive: drop the old column/table. Safe to deploy because the running code already ignores it.
This guarantees that rolling back release N+2 → N+1 (or N+1 → N) never requires restoring data.
Rollback procedure
Coolify keeps the N most recent image tags pinned by SHA (we never deploy :latest in prod, see #493). Rolling back is:
- Coolify UI → previous deployment → "Redeploy".
- The previous immutable image (
ghcr.io/vincentchalamon/bike-trip-planner-*:<previous-sha>) is pulled and started. MIGRATIONS_ON_BOOT=truemakes the previous container rundoctrine:migrations:migrateagain, which is a no-op when no migrations were added between the two SHAs (additive case) and a downgrade is not attempted automatically.- If the bad release introduced an additive migration we want to keep, do nothing else.
- If the bad release introduced a destructive migration (should not happen per the policy above), restore from the most recent PostgreSQL backup — covered by a future plan, out of scope here.
A detailed playbook lives in docs/runbooks/release-rollback.md (created as part of the P2.3 runbooks effort).
Consequences
Positive
- Schema and code are always in sync at boot: no manual step, no operator drift.
- Coolify healthchecks naturally gate traffic: bad migration → container unhealthy → no traffic shifted.
- The
MIGRATIONS_ON_BOOT=falseflag provides a safety valve when we want to deploy code without touching the schema. - Two-release destructive policy makes rollbacks safe by construction.
Negative
- Boot is slightly slower (a few hundred ms for
doctrine:migrations:migratewhen no migration is pending). - Two-release policy adds friction for schema cleanup work — but this is intentional and aligns with industry practice.
- A failed migration blocks the container from becoming healthy; recovery requires either fixing forward or flipping
MIGRATIONS_ON_BOOT=falseand intervening manually.
Neutral
- Workers (
messenger:consume) share the same image and the sameentrypoint.sh; they skip migrations becausecompose.yamlsetsMIGRATIONS_ON_BOOT=falseon the worker service. Only thephpservice runs them, and workers wait for it to be healthy viadepends_on.
Addendum — pre-launch exception (2026-08-07, #937)
The two-release destructive rule protects a rollback of a shipped release: it guarantees that reverting release N+2 to N+1 never needs data restored. That protection only has an object once a release has actually been cut.
No v* tag has ever been pushed, so deploy.yml has never shipped, the production database has never been created, and no release exists to roll back to. In this pre-launch window the grace period has nothing to protect: there is no prior release, and the table holds no production data.
On that basis, DROP TABLE trip_chat_message (#937) is deployed in the same pre-launch window that stopped writing to it (#929), rather than one release later. This is a one-time, pre-launch-only exception, not a change to the policy.
The two-release rule applies unconditionally again the moment the first v* tag is cut — from the first shipped release onward, every destructive migration must follow the additive → read-switch → destructive sequence above. Do not cite this addendum to justify a same-release DROP post-launch.
Addendum — pre-launch migration baseline reset (2026-08-07)
For the same pre-launch reason (no v* tag shipped, no production database, no release to roll back to), the 43 incremental development migrations were collapsed into a single baseline migration (Version20260807130000 + migrations/schema/baseline_schema.sql). It removes the accumulated create → alter → drop churn and gives one authoritative starting schema.
How the baseline was produced, and why not schema:create/diff:
- It is a
pg_dump --schema-only --no-owner --no-privilegesof a database with every pre-reset migration applied — so it carries the full DDL the ORM mappings do not describe (the PostGIS extension, theosm/tourism/provisionerschemas, GiST indexes, CHECK constraints). A baseline generated from the Doctrine entity mappings would have silently dropped all of that. - Faithfulness was verified two ways: a fresh database loaded from the SQL re-dumps schema-identical to the 43-migration schema, and the same holds when the baseline is applied through
doctrine:migrations:migrate.
This is a one-time, pre-launch-only reset, legitimate only because nothing has shipped. Once the first v* tag is cut, migrations are append-only again — never rewrite or squash a migration that has shipped, and regenerate a baseline only under the same "nothing in production" conditions.
Operational impact: existing local dev databases still record the 43 old versions and cannot adopt the baseline incrementally — they must be recreated (make start-dev on a fresh database volume). Fresh databases (CI, new checkouts) run the single baseline and are correct.