How to Run Database Migrations Safely on Deploy
Run migrations once per release, not in the start command: expand/contract, which tools take a lock, and how to run them as a one-off step on a Temps server.
Run database migrations once per release, as their own step, not inside your app's start command. Write each schema change so the old and new versions of the app can both run against it (expand, then contract), and trigger the migration from CI or by hand right after the build. On Temps, which has no release phase, that step is a one-off command in the running container, run over SSH or from your CI pipeline. Apply additive schema changes before activating code that requires them; the running release must contain the migration tooling while still working with the old schema.
TL;DR: Don't chain
migrate && start: with restarts and replicas the migration runs more than once, and a failed migration crash-loops every container. Run it once, from CI or a one-off command, and keep each change backwards compatible. Add the schema before enabling code that needs it, and remove old schema only after all dependent code has retired. Temps doesn't run migrations for you; see Run migrations on Temps below.
Why Not Run Migrations in the Start Command?
Chaining the migration into the container's start command (prisma migrate deploy && node server.js, alembic upgrade head && uvicorn ...) looks like automation, but the start command runs far more often than once per release:
- Every replica runs it. Scale to three containers and three migrations start at the same moment.
- Every restart runs it. A crash, an out-of-memory kill or a server reboot replays the migration step before the app comes back.
- A failed migration takes the app down with it. The process exits, the container restarts, the migration fails again, and you have a crash loop instead of a clear error.
- Long migrations hold up startup. A migration that rewrites a large table can outlast the platform's health-check timeout, so the deploy fails even though the migration was working.
Some tools take a database lock so that only one concurrent run applies changes (see the comparison table), which limits the damage from replicas. It doesn't fix the crash loop or the timeout, and several popular tools take no lock at all. Every framework guide on Temps gives the same advice for this reason: the Rails, Django and Laravel references all say not to put migrations in the start command (to deploy those stacks yourself, see Rails, Django and Laravel).
If you run a single replica and your tool takes a lock, chaining is workable for a hobby project. Treat it as a shortcut, not the pattern.
Where Should Migrations Run Instead?
There are three common places, and they all run the migration once per release:
- A platform release phase. Heroku's release phase, Render's pre-deploy command and Fly.io's
release_commandrun one command in a temporary instance of the new release before it takes traffic. Temps doesn't have this: Procfilerelease:lines are ignored, and there is no pre-deploy command setting. - A CI step. Your pipeline runs the migration once against the production database at the appropriate release boundary. It can deploy a migration-capable release first, provided that release still works with the old schema. The pipeline is also where you already see failures.
- A one-off command in a running container. You (or a script) exec into one container of the app and run the migration there. It uses the same image, dependencies and environment variables as the app, so connection strings and credentials are already in place.
For options 2 and 3, a post-deploy migration is safe only when the running code still works with the old schema. Stage a release that contains the migration but does not yet require the new tables or columns, apply the additive migration, then activate dependent code in a later release. Expand/contract preserves compatibility through that sequence; it does not make the order interchangeable.
How Do I Make Migrations Safe to Run Once, at Any Time?
Use expand/contract: split every breaking schema change across releases so that each migration is compatible with both the code before it and the code after it.
- Expand. Run the additive migration first: add new tables or columns as nullable (or with a default), and keep the old ones. Verify it succeeded before deploying or enabling code that writes to both shapes while still reading the old one.
- Backfill. Copy existing data into the new shape, in batches, outside the request path.
- Switch. Deploy code that reads the new shape.
- Contract. Once all running code, including jobs and replicas, has stopped reading and writing the old column, drop it in a later migration. Retire rollback versions that still need that column before dropping it.
Two rules cover most incidents: never add a NOT NULL column without a default in the same release as the code that needs it, and never drop or rename a column in the same release that stops using it. The zero-downtime Docker deployment guide walks through why both versions run side by side during a cutover.
How Do I Run Migrations on Temps?
Temps builds and runs your app, but it doesn't run migrations, has no release phase, and its CLI has no remote exec command yet. Run the migration in the app's container with docker exec over SSH.
Find the project ID, then the container name:
bunx @temps-sdk/cli projects show -p my-app --json # note the numeric "id"
bunx @temps-sdk/cli containers list -p <project-id> # note the container_name
Use a running release that includes the migration files and tooling but still operates against the old schema. Then run your tool in that container. After an additive migration succeeds, deploy or enable code that depends on it:
# Prisma
ssh your-server 'docker exec <container_name> npx prisma migrate deploy'
# Django
ssh your-server 'docker exec <container_name> python manage.py migrate'
# Alembic (FastAPI, Flask)
ssh your-server 'docker exec <container_name> alembic upgrade head'
# Rails
ssh your-server 'docker exec <container_name> bundle exec rails db:migrate'
# Laravel
ssh your-server 'docker exec <container_name> php artisan migrate --force'
The container gets the database connection from the managed PostgreSQL service you linked to the project. Temps injects POSTGRES_URL and the POSTGRES_* variables, not DATABASE_URL, so point your tool at those (for Prisma, url = env("POSTGRES_URL") in schema.prisma). Deploy with a database covers creating and linking the service.
Running it from CI
To make it automatic, deploy the migration-capable release first, then run the migration once. deploy waits for the deployment to finish unless you pass --no-wait. In this sequence the deployed release must still use the old schema; activate dependent code only after the migration succeeds:
bunx @temps-sdk/cli deploy -p my-app -e production -y
bunx @temps-sdk/cli containers list -p "$TEMPS_PROJECT_ID" --json # read container_name
ssh deploy@your-server "docker exec $CONTAINER_NAME npx prisma migrate deploy"
# Only after success: deploy or enable code that requires the expanded schema.
The container name can change from one deployment to the next, so look it up in the job rather than hard-coding it.
The first deploy
Before switching traffic to a new deployment, Temps health-checks it by requesting / (or the path you set). On a first deploy the tables don't exist yet, so if / queries the database, the deploy fails before you get the chance to migrate. Point the check at a route that doesn't touch the database:
# .temps.yaml
health:
path: /health
Which Migration Tools Take a Lock?
A lock decides what happens if two runs overlap, for example a CI retry while a manual run is still going. Running once per release makes overlaps rare, but it's worth knowing what your tool does:
| Tool | Records applied migrations in | Concurrent runs |
|---|---|---|
Prisma Migrate (migrate deploy) | _prisma_migrations | Takes a PostgreSQL advisory lock (pg_advisory_lock) |
Rails (db:migrate) | schema_migrations | Takes an advisory lock on PostgreSQL and MySQL |
| golang-migrate | schema_migrations | Takes an advisory lock on PostgreSQL |
| Flyway | flyway_schema_history | Locks on PostgreSQL |
| Liquibase | databasechangelog | Uses its databasechangeloglock table |
Laravel (migrate) | migrations | No lock unless you pass --isolated, which takes a cache lock |
Django (migrate) | django_migrations | No lock |
Alembic (upgrade head) | alembic_version | No lock unless you add one in env.py |
A migration history table records what has been applied; it isn't a lock. Two processes that read the history at the same time can both decide to apply the same migration.
What Happens on Temps When a Deploy or a Migration Fails?
They fail independently, because they're separate steps:
- The deploy fails its health check. Temps marks the deployment as failed and the current version keeps serving. Traffic never switches to the broken version.
- The migration fails after a successful deploy. The migration-capable release is already live and must continue to work with the old schema. A failed migration can leave partial changes, depending on the tool and transaction boundaries. Inspect that state and follow the tool's recovery procedure before retrying; do not activate dependent code until the migration succeeds. Read the error in the command's output, or in the app's logs with
bunx @temps-sdk/cli runtime-logs -p my-app -f. - You roll back. A rollback redeploys a previous version of the code. It doesn't touch the database, so a rolled-back app must work with the current schema, which expand/contract also guarantees.
Nothing rolls back on its own after a deploy has gone live.
How Do I Run Long Backfills?
Keep schema changes (DDL) in the release step, and move data backfills out of it. On Temps, a cron job defined in .temps.yaml calls an HTTP endpoint in your app on a schedule, so a backfill can be an authenticated route that processes one batch per call:
# .temps.yaml
cron:
- path: "/api/internal/backfill"
schedule: "*/10 * * * *"
name: "Backfill orders.total_cents"
Temps calls the path with a GET request and an Authorization: Bearer header carrying the CRON_SECRET it injects; check that header in the route. Have each call process a fixed batch and exit, so a slow batch never overlaps the next call. Remove the entry once the backfill is complete.
Frequently Asked Questions
Does Temps run database migrations automatically on deploy?
No. Temps builds and starts your app, health-checks it and switches traffic, but it doesn't detect or run migration tools, and it has no release phase or pre-deploy command. Run the migration as a one-off step from CI or with docker exec over SSH. If it runs after a deploy, that release must still work with the old schema; activate dependent code only after the additive migration succeeds.
Can I put my migration in a Procfile release line on Temps?
No. Temps uses only the Procfile's web: line, as the start command. release: and worker: lines are ignored, so a migration there never runs.
Is it ever fine to run migrations in the start command?
Only for a single-replica app whose migration tool takes a lock, such as Prisma Migrate or Rails on PostgreSQL. With replicas, restarts or a tool without a lock, the same migration can run several times at once, and a failing migration turns into a crash loop.
Does rolling back a deployment roll back the database?
No. A rollback redeploys earlier code against the current schema. Write migrations with expand/contract so the previous release still works after a migration has run, or write and apply a new migration that reverses the change.
Which variable holds the database URL on Temps?
A linked managed PostgreSQL injects POSTGRES_URL plus POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD. It doesn't inject DATABASE_URL; read POSTGRES_URL, or set DATABASE_URL yourself as a project variable.
Get weekly updates