Where database migrations should run in a deploy.
Run migrations in a release step: once per production deploy, after the build succeeds and before the new version starts. Not in the build, which runs before the code is known to be good and often on previews too. Not in the start command, which runs on every restart. Then make each migration safe for the version still running.
Facts checked
Build, release or start: what each one costs
A deploy has three places where you can put migrate. Only one of them runs exactly once, at the right moment.
| Where | When it runs | What goes wrong |
|---|---|---|
| Build command | Every build, production and preview, before the new code has started once | A build that later fails its health check has already changed the schema. A preview of an unmerged branch migrates the shared database. |
| Release command | Once per production deploy, after a good build, before the new version starts | Nothing structural. The old version is still serving while it runs, so the migration has to suit the old code too (below). |
| Start command | Every time the process starts: deploys, crash restarts, wake from sleep | Boot gets slower by the length of the migration, a long one can blow the health-check window, and with several instances they all race. |
Why not in the build
It is tempting because the build already has DATABASE_URL and it is one line: npm run build && npx prisma migrate deploy. The problem is ordering. The build finishes, the new version starts, and only then do you learn whether it boots. If it doesn’t, the schema has moved and the code that matches it is not running. Rolling back to the previous commit now means old code against a new schema.
Previews make it worse. On most platforms, Canner included, a pull-request preview builds with the same database as production unless you split it out. A migration in the build means opening a pull request changes production.
Docker images have the same trap from the other side: a RUN step that migrates inside the image build needs database credentials at image-build time and runs once per image, not once per deploy.
Why not in the start command
npx prisma migrate deploy && node server.js works, and Canner’s start command accepts it. But it runs every time the process starts. On Canner that includes crash restarts and, on Starter, every wake after the 3-hour sleep, so the first visitor after a quiet night waits for the migration check as well as the boot.
It also eats your health-check budget. Canner gives a new version 30 seconds to answer on its health-check path. A migration that rewrites a large table inside the start command can use that up, the deploy is marked failed, and the migration may have half-finished. On platforms with several instances, every instance runs it at once, and you are relying entirely on the tool’s lock.
Two runs at once: locks
A release step runs once per deploy, but two deploys can still overlap: a push lands while the previous release is running, or someone runs the command by hand. Most tools take a Postgres advisory lock so the second run waits. Heroku’s release-phase docs tell you to take one yourself if your tool doesn’t.
Advisory locks belong to a database session. Behind a transaction-mode pooler such as PgBouncer, consecutive statements can land on different server connections and the lock does nothing, which is why Rails documents turning advisory_locks off behind PgBouncer. On Canner both DATABASE_URL and the migration login are session connections, so leave the locks on.
- Prisma:
migrate deploytakes an advisory lock with a 10-second timeout that cannot be changed. If it times out, run it again. Since 5.3.0PRISMA_SCHEMA_DISABLE_ADVISORY_LOCKturns it off; don’t, unless nothing else can run Migrate at the same time. - Rails: Active Record uses advisory locks on Postgres by default.
- Laravel:
php artisan migrate --isolatedtakes an atomic lock through your cache driver, so every server must share the same cache store. A second run that finds the lock held exits successfully without migrating. - Django:
migratetakes no lock of its own. Rely on the release step running once, or wrap it withpg_advisory_lockyourself.
Make reruns harmless
A migration tool records what it applied in a ledger table (_prisma_migrations, schema_migrations, django_migrations, migrations) and skips those next time, so running the command twice is fine. What breaks reruns is a migration that half-applied.
Postgres can roll back schema changes inside a transaction. Django runs migrations in a transaction on Postgres by default, and Rails wraps each migration in one. Keep that on. The exceptions are statements Postgres refuses inside a transaction, such as CREATE INDEX CONCURRENTLY; those need atomic = False in Django or disable_ddl_transaction! in Rails, and should sit in a migration of their own so a failure leaves nothing else half-done.
Seed data belongs in the release step too, written as upserts (INSERT … ON CONFLICT DO NOTHING) so the second run changes nothing.
Migrate as the owner, run the app as less
The login that can ALTER TABLE is the login an attacker wants. Give the running app a role that can read and write rows but not change the schema, and keep the owner for migrations.
On Canner’s dedicated instance, canner db least-privilege (see the CLI) does this in one step: the app gets a read-write role as DATABASE_URL, every well-known migration ledger stays read-only for it, and the owner is handed to the release command alone as DATABASE_URL_MIGRATE. Builds and the running app never see it. Point your tool at it in the release command:
# Prisma (schema.prisma: url = env("DATABASE_URL"))
DATABASE_URL=$DATABASE_URL_MIGRATE npx prisma migrate deploy
# Django, Rails, Laravel: same idea
DATABASE_URL=$DATABASE_URL_MIGRATE python manage.py migrate --noinputThe command for each framework
Prisma and Drizzle need their CLI at release time. If prisma or drizzle-kit is in devDependencies and your install prunes them, the release command fails with command not found; move the CLI to dependencies. Prisma’s newest docs replace migrate deploy with db migrate; the rule is the same, it goes in the release command.
Drizzle `push` or `migrate`? drizzle-kit push applies the schema diff directly with no SQL files. Drizzle calls it the best approach for rapid prototyping, and says some teams use it in production. It also asks before data-loss statements unless you pass --force, which a deploy can’t answer. For a shared production database, generate plus migrate gives you SQL you can read in review before it runs.
Rails 8 on Canner: with no release command set, Canner runs bundle exec rails db:prepare before each new version starts, which also sets up the Solid Cache, Queue and Cable databases. See Rails hosting.
| Framework | Release command | Transaction on Postgres | Concurrent runs |
|---|---|---|---|
| Rails † † | bundle exec rails db:migrate (or db:prepare, which also creates a missing database and is safe to repeat) | Each migration wrapped; opt out with disable_ddl_transaction! | Advisory lock by default |
| Django † | python manage.py migrate --noinput | Yes by default; opt out with atomic = False | No lock of its own |
| Laravel † | php artisan migrate --force (without --force it asks before running in production) | Depends on the migration | --isolated with a shared cache store |
| Prisma 7 † † | npx prisma migrate deploy. Never migrate dev, which Prisma says is for development only | Per migration file | Advisory lock, 10 s timeout |
| Drizzle † † | npx drizzle-kit migrate, running the SQL files drizzle-kit generate wrote and you reviewed | Per migration | See the Drizzle docs for your driver |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. Rails (2026-10-29) · Rails (2026-10-29) · Django (2026-10-29) · Laravel (2026-10-29) · Prisma 7 (2026-10-29) · Prisma 7 (2026-10-29) · Drizzle (2026-10-29) · Drizzle (2026-10-29)
The old version runs against the new schema
A release step changes the database while the previous version is still serving. That holds on every platform with a release phase. For a moment, and for longer if you roll back, old code meets the new schema.
So each deploy’s migration has to work with the code before it. Adding a nullable column or a new table is safe. Dropping or renaming a column the old code reads is not: requests fail until the new version is up, and a rollback to the old commit fails outright.
- Rename in steps: add the new column, write to both, backfill, switch reads, stop writing the old one, drop it in a later deploy.
- Drop in two deploys: first ship code that no longer reads the column (Rails:
ignored_columns), then drop it. - NOT NULL on an existing column: add it nullable, backfill, then add the constraint.
- Indexes on big tables:
CREATE INDEX CONCURRENTLY, in its own non-transactional migration.
Questions
Should I run migrations in the Dockerfile?
No. An image build should not need production database credentials, and an image built once can be deployed many times or not at all. Run migrations in a release step, or a one-off job, that uses the image you are about to start.
Is it safe to run migrations when the app starts?
It works with one instance and small migrations, but it runs on every restart and wake, slows boot, can exceed the health-check window, and with several instances relies on the tool’s lock. A release command runs once per deploy instead.
Why does prisma migrate deploy say it timed out acquiring the advisory lock?
Another Migrate command held the lock for more than 10 seconds, or the connection goes through a transaction-mode pooler where session locks don’t hold. Rerun it, check for an overlapping deploy, and point the release command at a direct or session connection.
What happens on Canner if the release command fails?
The deploy stops before the new version starts and the current version keeps running. The command’s output is in the deploy log. Fix it and retry the deploy.
Can I use drizzle-kit push in production?
Drizzle says some teams do. It writes no migration files, so there is nothing to review before it runs, and it asks before data-loss statements unless you pass --force. For a shared production database, generate and migrate is easier to review.
Put your migrations in a release command.
Every Canner project gets a Postgres database and a release command that runs once per deploy, never on previews. Setup for each framework is in hosting by framework.