Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Maintenance Mode on Heroku: One Command, and the Migration It Cannot Save

Sean

Platform Writer

Aug 20, 2026
7 min read

heroku maintenance:on puts a holding page in front of your app so a long migration can run without requests writing to a half-migrated schema. It parks web traffic. It does not park your workers, your scheduled jobs, or anything already mid-request.

Maintenance Mode on Heroku: One Command, and the Migration It Cannot Save

The command is trivial and well documented. What makes it worth writing about is the gap between what people assume it does — freeze the application — and what it actually does, which is intercept HTTP requests at the router. Those are different things, and the difference is where migrations go wrong.

Table of contents

The commands

# Turn it on
heroku maintenance:on --app myapp

# Check the current state
heroku maintenance --app myapp

# Turn it off
heroku maintenance:off --app myapp

While it is on, every request to your web dynos is intercepted before it reaches your application and answered with a static maintenance page. Your app does not see the request at all, which is exactly what you want when the schema is in flux.

The default page is generic. Replacing it with something branded and specific takes one config variable pointing at a publicly reachable HTML file:

heroku config:set MAINTENANCE_PAGE_URL=https://assets.example.com/maintenance.html --app myapp

Host that page somewhere that is not the application you are taking down. This sounds obvious and is a real mistake people make — a maintenance page served by the app in maintenance mode is a maintenance page nobody sees.

A useful page says three things: that this is planned, roughly how long, and where to look for updates. A page that only says we will be back soon generates support tickets, because the reader cannot tell whether you know.

What it does not stop

Three categories keep running, and each one has ended a migration badly.

  • Worker dynos. Background processes are unaffected. A queue consumer will happily keep processing jobs against a table you are in the middle of altering. Scale workers to zero before the migration, and remember to scale them back afterwards.
  • Scheduled jobs. Anything on a scheduler fires on time regardless of maintenance mode. A nightly aggregation that lands mid-migration is a bad night.
  • In-flight requests. Enabling maintenance mode does not terminate requests already being served. A long-running write started a second before you flipped the switch is still running a second after.

So the actual sequence for a schema change that cannot tolerate concurrent writes is longer than one command:

# 1. Stop new web traffic
heroku maintenance:on --app myapp

# 2. Stop background work
heroku ps:scale worker=0 --app myapp

# 3. Let in-flight requests drain
sleep 30

# 4. Migrate
heroku run rake db:migrate --app myapp

# 5. Bring workers back
heroku ps:scale worker=1 --app myapp

# 6. Let traffic in
heroku maintenance:off --app myapp

Step three is the one that gets dropped when someone is in a hurry, and it is the one that causes the corruption you spend the next day untangling.

Note also that maintenance mode returns a 503, which is correct — it tells crawlers the outage is temporary and not to drop your pages from an index. A maintenance page returning 200 is a page that can get indexed as your homepage.

Most migrations do not need any of this

Maintenance mode is a blunt instrument, and reaching for it by default trains a team to accept downtime for changes that do not require it.

These are safe to run against a live application, with no maintenance window at all:

  • Adding a nullable column.
  • Adding a table.
  • Creating an index concurrently — Postgres supports building an index without taking a write lock.
  • Adding a column with a default, on any modern Postgres, since the default is stored as metadata rather than rewritten across every row.

These genuinely need a window, or a more careful strategy:

  • Renaming a column, if old and new code cannot both work.
  • Changing a column type in a way that rewrites the table.
  • Adding a NOT NULL constraint to a populated column without a prior backfill.
  • Any data migration large enough to hold a lock for a meaningful time.

The expand-and-contract pattern turns most of the second list into the first. Rather than renaming email to email_address in one step:

  1. Expand. Add the new column. Deploy code that writes to both and reads from the old one.
  2. Backfill. Copy the data across in batches, live, with no lock held for long.
  3. Switch. Deploy code that reads from the new column and still writes to both.
  4. Contract. Once nothing references the old column, drop it.

Four deploys instead of one, and zero seconds of downtime. Worth it for anything user-facing; overkill for an internal tool where five minutes offline costs nothing. The judgement is about who is watching, not about which is technically purer.

Rehearse the migration, not just the plan

The number that decides whether you need a window is how long the migration takes on production-sized data, and that number is unknowable from a development database with a thousand rows.

Get it from a fork of production:

# Create a copy of the production database
heroku addons:create heroku-postgresql:standard-0 --fork DATABASE_URL --app myapp

# Run the migration against the copy and time it
time heroku run rake db:migrate DATABASE_URL=$FORK_URL --app myapp

A migration that takes eleven minutes on real data is a very different conversation from one that takes eleven seconds, and the fork is the only honest way to find out which you have.

Two more things worth having ready before you start:

  • A written rollback. Not we will restore from backup — an actual reverse migration you have run against the fork. Restoring a backup means losing everything written since it was taken.
  • A statement timeout. Setting a lock timeout means a migration that cannot acquire its lock fails fast instead of queueing behind a long query and blocking every write in the system while it waits.
SET lock_timeout = '5s';
SET statement_timeout = '10min';
ALTER TABLE users ADD COLUMN email_address text;

How this fits the rest of the stack

Maintenance mode is a router-level switch, and the interesting work is everything you do around it: draining requests, stopping workers, knowing how long the migration takes, and having a way back. Teams that get good at this mostly get good at not needing it.

RunxBuild gives you the pieces that make the window short or unnecessary. Managed MySQL and Postgres come with backups and connection limits, on private networking so the migration is not racing traffic from anywhere unexpected. Web services deploy from GitHub with build and runtime logs in one place, so a migration that runs during a release is visible rather than inferred, and a release that goes wrong rolls back to the previous deploy. Autoscaling moves between a floor and a ceiling plan you pick, so a backfill does not compete with a traffic spike for the same capacity. To see what a service, its database and its storage add up to, the RunxBuild hosting calculator lists them as separate line items.

Useful related references:

FAQ

How do I enable maintenance mode on Heroku?

Run heroku maintenance:on --app myapp. Every request to your web dynos is then intercepted at the router and answered with a maintenance page before reaching your application. heroku maintenance reports the current state, and heroku maintenance:off restores normal traffic.

Does maintenance mode stop background jobs?

No, and this is the most common misunderstanding. Worker dynos and scheduled jobs keep running, so a queue consumer can happily write to a table you are altering. Scale workers to zero before the migration and back up afterwards, and remember that in-flight web requests are not terminated either.

How do I set a custom maintenance page?

Set the MAINTENANCE_PAGE_URL config variable to a publicly reachable HTML file. Host that file somewhere other than the application you are taking down — a maintenance page served by the app in maintenance mode does not load. Say that the outage is planned, roughly how long, and where to check for updates.

Do I need maintenance mode for every migration?

No. Adding a nullable column, adding a table, or creating an index concurrently are all safe against live traffic. Renames, type changes that rewrite the table, and large data migrations are the ones that need care — and the expand-and-contract pattern converts most of those into a series of safe steps with no downtime.

How long will my migration actually take?

You cannot tell from a development database. Fork the production database, run the migration against the copy, and time it. That number decides whether you need a window at all. While you are there, write and test the reverse migration, because restoring a backup loses everything written since it was taken.

#maintenance mode heroku#heroku#deployment#database migration#downtime