Before you start: is moving actually right for you?
Heroku is not a bad platform. If you depend on Shield or Private Spaces for compliance, or on a specific add-on with no equivalent elsewhere, the migration below will cost you more than you save. Stay put.
The case for moving is narrower and mostly about memory. A Standard-1X dyno is $25/mo for 0.5 GB of RAM, the same memory as a $7 Basic dyno. Standard-2X doubles it to 1 GB for $50. If you are paying Standard prices for an app that needs more memory than compute, that is where the money goes. Prices verified 13 August 2026 from heroku.com/pricing.
Budget an hour for a simple app, half a day for one with several add-ons and a large database. Nothing below requires downtime until the DNS step, and that step is designed to avoid it.
Step 1 — Take an inventory
Run these against your existing app so you know exactly what you are recreating:
heroku apps:info -a your-app
heroku addons -a your-app
heroku config -a your-app
heroku ps -a your-app
heroku domains -a your-app
heroku ps matters more than people expect. If you run a worker or clock process alongside web, each one becomes its own deployment here, not a single container.
Step 2 — Export your config vars
Get them in a form you can paste rather than retyping:
heroku config -a your-app --shell > .env.heroku
That produces KEY=value lines. Two things to fix before importing:
- Drop
DATABASE_URL. It points at Heroku Postgres and will be replaced. Leaving it in is the single most common way people end up with a live app still writing to their old database after cutover. - Drop add-on-injected vars for anything you are not carrying across —
REDIS_URL,CLOUDINARY_URL,SENDGRID_API_KEYand similar. Recreate them pointing at the new services.
Everything else — API keys, secrets, feature flags — moves across unchanged.
Step 3 — Capture and restore the database
This is the part worth doing carefully. Take a fresh backup and download it:
heroku pg:backups:capture -a your-app
heroku pg:backups:download -a your-app # writes latest.dump
latest.dump is a PostgreSQL custom-format dump, so restore it with pg_restore rather than psql:
pg_restore --verbose --clean --no-acl --no-owner \
-d "postgres://user:pass@host:5432/dbname" latest.dump
--no-acl --no-owner is not optional. The dump carries Heroku's role names, and without those flags the restore fails on every GRANT and ALTER OWNER statement referencing a role that does not exist on the target.
Verify before moving on. A restore that "looked fine" but silently skipped a table is worse than one that failed loudly:
psql "$NEW_DATABASE_URL" -c "\dt"
psql "$NEW_DATABASE_URL" -c "SELECT count(*) FROM your_busiest_table;"
Compare that count against the same query on Heroku. If your app is actively taking writes, expect a small difference — you will close that gap in step 7.
Step 4 — Map your add-ons
Most Heroku add-ons have a direct equivalent, either as a managed database or from the one-click catalogue.
| Heroku add-on | Equivalent |
|---|---|
| Heroku Postgres | Managed PostgreSQL |
| Heroku Key-Value Store / Redis | Managed Redis |
| JawsDB / ClearDB MySQL | Managed MySQL or MariaDB |
| Papertrail, Logentries | Container logs, or self-hosted Grafana Loki |
| Heroku Scheduler | Scheduled tasks on the deployment |
| SendGrid, Mailgun | Keep them — they are independent services, just move the API key |
| New Relic, Scout APM | Keep them, or self-host Grafana and Prometheus |
Third-party SaaS add-ons billed through Heroku (SendGrid, Twilio, Stripe) are not really Heroku features. Create an account directly with the vendor, move the API key, and remove the add-on. You often pay less buying direct.
Step 5 — Buildpacks and the Procfile
This is where a migration is most likely to surprise you, so read it before you start rather than after.
Heroku builds with buildpacks and starts processes from a Procfile. Hostwares builds from a detected framework or a Dockerfile. For the large majority of apps — Node, Python, Ruby, Go, PHP, static sites — detection handles it and you do not think about buildpacks at all.
Where you do need to think:
- Your start command lives in the Procfile. Copy it into the deployment's start command.
web: gunicorn app:applicationbecomes a start command ofgunicorn app:application. - Extra process types become extra deployments. A
worker: celery -A tasks workerline is a second deployment from the same repository with a different start command, not a setting on the first. - Custom or community buildpacks do not carry over. If you rely on one —
heroku-buildpack-apt, a headless Chrome buildpack, anything installing system packages — write a Dockerfile instead. That is the honest answer, and it is usually a ten-line file. - Check your language version pin. A
runtime.txt,.python-versionor theenginesfield inpackage.jsonis respected by detection, but verify it built against the version you expected before cutting over.
If your app is a plain web: process on an official buildpack, none of this applies and you can move on.
Step 6 — Deploy and test on the temporary domain
Connect the same GitHub repository, import the cleaned .env.heroku, point DATABASE_URL at the restored database, and deploy. Every new deployment gets a working subdomain with SSL, so you can exercise the app properly while the old one is still serving live traffic.
Test the paths that break quietly:
- Anything writing to the database — confirm it lands in the new one, not the old
- Background jobs, if you moved a worker process
- File uploads, if you were using ephemeral dyno storage (that data does not survive a Heroku restart either, which is worth knowing)
- Outbound email and webhooks — check the vendor received them
- Anything with an IP allowlist. A new host means a new outbound IP.
Step 7 — Cut over DNS without downtime
The order here is what avoids a gap.
A day before: drop your DNS TTL to 300 seconds. If it is currently 3600 or 86400, this is the step that decides whether cutover takes five minutes or a day, and it has to be done in advance to take effect.
At cutover:
- Put the Heroku app into maintenance mode if you cannot tolerate split writes:
heroku maintenance:on -a your-app - Take a final incremental database sync — repeat step 3 against the new database, or replay the delta if your data model allows it
- Add your custom domain to the new deployment and let the certificate issue
- Update the DNS record to point at the new host
- Watch both sets of logs. Traffic drains from Heroku over one TTL.
Do not delete the Heroku app the same day. Leave it running for a week. It costs one more month at most and it is the only rollback you have.
For a truly zero-downtime cutover on a write-heavy app, the usual approach is to make the application write to both databases for a short window, or accept a two-minute maintenance window during a quiet hour. Anyone promising zero downtime with no dual-write and no maintenance window on a stateful app is skipping over the hard part.
Step 8 — After cutover
- Confirm backups are running on the new database, and test a restore. A backup you have never restored is a hypothesis.
- Re-point monitoring and uptime checks
- Update any IP allowlists at third parties
- Cancel Heroku add-ons before the app itself — add-ons bill separately and outlive a deleted app in some cases
- Only then remove the Heroku app
What this actually saves
A representative case: one Standard-1X dyno at $25/mo with Heroku Postgres Essential-1 at $9/mo is $34/mo, for 0.5 GB of application memory. The equivalent here is a Standard pack at $7/mo with 2 GB, plus a managed database pack at $3/mo — $10/mo, with four times the memory.
Work out your own figure with the Heroku cost calculator, which uses Heroku's published rates rather than an estimate.
Whether that is worth a morning of work depends on your bill. At $34/mo it pays back in a month. If you are on an Eco dyno at $5, it does not — and you should stay where you are.