Stop Running Migrations by Hand: Heroku's Release Phase Does It for You
Heroku's release phase runs migrations automatically between build and traffic shift, turning a forgotten manual step into a deploy-blocking safety gate. Here's how to set it up and where its limits are.
24 Feb 2026, 00:32 UTC

Here's a deployment failure mode that shows up on every team eventually: someone pushes new code to Heroku, the deploy succeeds, and the app starts throwing 500s because the new code expects a database column that doesn't exist yet. Someone forgot to run heroku run rails db:migrate after the deploy — or ran it a few minutes late, while the new dynos were already serving traffic against the old schema.
Heroku's release phase exists to close exactly this gap. It's a small Procfile addition that moves migrations (and similar one-off tasks) from "thing a human must remember" to "thing the platform runs automatically, before the new code ever sees a request."
What release phase actually does
A Heroku deploy has a natural ordering: build the slug, then start new dynos from it. Release phase inserts a step in between. You declare a release process type in your Procfile:
web: gunicorn myapp.wsgi
release: python manage.py migrateWhen you deploy, Heroku runs that command in a one-off dyno built from the same slug and the same config vars as the release being deployed. Only if the command exits zero do the new web dynos start receiving traffic. If it exits non-zero, the deploy fails and the previous release keeps serving — your users never see the broken state.
That last property is the real win. It's not just automation; it's a safety gate. A failed migration stops the rollout instead of shipping half-deployed code.
Why it beats `heroku run` after the fact
The manual workflow has three problems, and release phase fixes all of them:
- The race window. Between the deploy finishing and you running
heroku run ... migrate, new code is live against an unmigrated schema. Release phase eliminates the window entirely — migration happens before traffic shifts. - The forgotten step. Any deploy process that depends on a human remembering a second command will eventually skip it. Pipelines and
git push heroku mainboth trigger release phase automatically. - Code/schema skew. Because the release dyno uses the new release's slug, the migration runs with the exact code being deployed — not whatever happens to be on your laptop.
A worked example: migrations plus a smoke check
Rather than putting a long command in the Procfile, point it at a script. This keeps the Procfile readable and lets the release step grow over time:
# Procfile
web: bundle exec puma -C config/puma.rb
release: ./bin/release-tasks.sh#!/usr/bin/env bash
# bin/release-tasks.sh — run from the app root during release phase
set -euo pipefail
bundle exec rails db:migrate
bundle exec rails runner 'puts "Smoke check: " + User.count.to_s + " users"'Two things worth noting in that script. First, set -euo pipefail makes any failing command fail the whole release — that's what turns the safety gate on. Without it, a failing migration followed by a successful echo would still exit zero and let the broken deploy through. Second, the smoke check is cheap insurance that the app can actually boot and reach the database with the new schema.
After deploying, verify the release step ran (from your machine, with the Heroku CLI authenticated):
heroku releases --app your-app-name
heroku logs --dyno release --app your-app-nameThe first command shows the release with its release-phase output attached; the second shows the release dyno's logs. One caveat: this output is less prominent than build logs in the dashboard, so when a deploy fails at the release step, check heroku releases first — the answer is usually there.
The trade-offs you need to design around
Release phase is not a universal migration solution, and its limits shape how you should use it:
- It blocks the deploy. The release command runs to completion before new dynos start, and there's a time limit. A migration that locks a huge table for twenty minutes will time out and fail the deploy — or worse, succeed slowly on every pipeline promotion. Keep release phase for fast, backward-compatible schema changes (adding a column, adding a table). Heavy data backfills belong in a separate background job or a manually triggered one-off dyno.
- No database rollback. If the release command fails partway through, Heroku withholds the new code release, but whatever the migration already did to the database stays done. Write migrations to be idempotent and safe to retry, because the next deploy attempt will run them again.
- Destructive changes still need expand-and-contract. Dropping or renaming a column in release phase will break the old dynos that are still serving until the new ones start. The standard pattern applies: deploy code that tolerates both shapes, migrate, then deploy the cleanup in a later release.
Exact timeout values and behavior can vary by dyno type and change over time, so check the current Heroku Dev Center page on release phase before relying on specific limits — and time a representative migration in staging to confirm you have comfortable headroom.
Try it this week
The verification path is short. Add a trivial migration to a staging app, add the release line to the Procfile, deploy, and confirm via heroku releases that the migration ran before the web dynos came up. Then deliberately break the release command — make it exit 1 — and confirm the old release keeps serving. Once you've watched the safety gate catch a bad deploy in staging, you'll never want to go back to remembering heroku run by hand.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.