up and a down.
They run automatically
Migrations are applied during application startup, before anything else is built. Bothcmd/api and cmd/worker do this, so docker compose up on an
empty database produces a working schema with no separate step.
Running them at startup rather than as a deployment step is a decision about
failure modes: a process that finds a schema it does not understand should not
start, and a schema that lags the binary is exactly the situation that produces
confusing runtime errors rather than a clear startup one.
Concurrent starts are safe. The runner takes a PostgreSQL advisory lock around
the whole migration run, so several instances starting at once serialise rather
than racing, and applying nothing when the schema is already current is a no-op.
Running them by hand
Useful when you want to prepare a database before deploying the binaries, or to see what a migration does before it runs unattended.cmd/migrate reads two settings and nothing else:
Inside the container the files are at
/app/migrations, copied in by the
Dockerfile’s final stage.
Rolling back
go run ./cmd/migrate down N — or make db-migrate-down STEPS=N — reverses the
last N applied migrations. The count is mandatory; the command prints what each
down file drops before running, --dry-run stops there, and a rollback that
would drop the audit trail or an identity table is refused without
--force-drop-audit. It exists for development and for tests. Nothing calls it
at startup.
Every migration having a down is what makes an upgrade reversible in
principle, but a down returns the schema, not the data it dropped. Before
upgrading a deployment that holds anything you care about, back up PostgreSQL
first. See Upgrades.
Adding a migration
Two files, numbered in sequence,up and down:
down at the same time as the up, while you still remember what
the up did. A migration whose down is written months later during an
incident is a migration with no down.
When startup fails on a migration
The process exits withrunning migrations: … and does not serve traffic. Three
common causes:
- A dirty schema version. golang-migrate marks the version dirty when a
migration fails part-way. Resolve the partial state manually, then clear the
dirty flag in
schema_migrationsbefore restarting. - Insufficient privileges. The role in
DATABASE_URLneeds DDL rights on the database, not just DML. - The wrong
MIGRATIONS_PATH. Runningcmd/migratefrom a directory other thanbackend/resolves the default relative path somewhere it will not find the files.

