Guide
How to run database migrations safely on every deploy
Run migrations and seeds as a pre-deploy step so a failed migration blocks the release instead of breaking production. Includes idempotent seed patterns.
· 3 min read
In short
- Run migrations as a pre-deploy step, not on app startup
- A failed migration stops the release and the old version keeps serving
- Seeds must be idempotent because pre_deploy runs on every deploy
Database migrations are the part of a deploy where a mistake costs the most. Code can be rolled back in seconds. A half-applied schema change cannot. The good news is that a small amount of structure removes most of the danger.
Where migrations should run
There are three common places, and only one of them is a good default.
On app startup. Simple, but with more than one replica every instance races to migrate. A slow migration also delays startup, which can trip health checks and restart loops.
By hand. Works on day one, then someone forgets, and production is running code that expects a column that does not exist.
As a step of the deploy, before the new version goes live. This is the one to use. The migration runs exactly once per deploy, and if it fails, the release stops.
Using pre_deploy
In Prongo this is one field on the service:
services:
api:
build: ./apps/api
depends_on: [database]
pre_deploy:
- npm run migrate
- npm run seed
The commands run in a one-off container built from the same image, with the same environment and secrets, so DATABASE_URL works exactly as it does in your app. They run in order, after database is up and before the new version is live. If one fails or runs longer than five minutes, the deploy fails and the previous version keeps serving. Output appears in the deploy log.
Full details are in the config reference.
Migrations are safe to repeat, seeds are not by default
Migration tools such as Prisma, Drizzle and Alembic record which migrations already ran, so running them on every deploy is harmless. Seeds are different: nothing records that they ran. pre_deploy runs on every deploy, including redeploys and branch previews, so a naive seed will duplicate its rows each time.
Make seeds idempotent by letting the database skip what exists:
INSERT INTO plans (id, name) VALUES (1, 'free')
ON CONFLICT (id) DO NOTHING;
Rules that prevent bad days
- Never
DROP,TRUNCATEor reset tables in a pre-deploy command. Run destructive or one-off data changes yourself, outside the deploy. - Use your migration tool’s lock if two deploys can overlap, for example when a branch preview and a main deploy run together.
- Split risky changes in two releases. Add the new column and ship code that tolerates both shapes. Remove the old column in a later deploy. This is the expand/contract pattern, and it is what makes zero-downtime deploys safe for the schema too.
- Keep migrations fast. A multi-minute table rewrite belongs in a planned maintenance window, not a routine push.
Why this matters more in a monorepo
When the API, a worker and a frontend deploy together, the schema must be ready before any of them starts. Declaring depends_on: [database] and attaching pre_deploy to the service that owns the schema gives you that order for free. See deploying a monorepo with multiple services for the full picture.
Frequently asked questions
When should database migrations run in a deployment?
After the database is available and before the new application version receives traffic. Running them in a separate pre-deploy step means a failed migration stops the release while the old version keeps serving.
Should migrations run when the app starts?
It is risky. With several replicas, every instance tries to migrate at the same time, and a slow migration delays startup and fails health checks. A single pre-deploy step is safer.
Are seed scripts safe to run on every deploy?
Only if they are idempotent. Use INSERT ... ON CONFLICT DO NOTHING or check for existing rows first. A seed that inserts blindly will duplicate data on every deploy.
What happens if a migration fails during deploy?
In Prongo the deploy fails and the previous version keeps running. The same pattern is what you should aim for on any platform.