Disclosure: I'm a senior backend tech lead and I run HostingGuru. Six of these 12 migrations landed on HostingGuru. The other six went to Render, Railway, Fly.io, or back to a VPS. The playbook below works regardless of destination — it's the Heroku-side problems I want you to skip.
Since Heroku announced "sustaining engineering mode" in February 2026, I've been the person clients call when they need to get off the platform without breaking production. As of this week I've done 12 migrations — Rails apps, Django apps, Node services, Python workers — for clients ranging from 200-MAU side projects to 80K-MAU consumer apps.
If you're staring at your Heroku dashboard wondering when to leave, this is the post I wish someone had written before I started doing these.
It's two parts: the playbook (the order of operations that works), and the 7 things that bit me (the stuff nobody warns you about until you hit it at 11pm on launch night).
Part 1 — The playbook
Every migration I've done follows the same eight-step order. I've tried shuffling it. The shuffling always costs me. Just do it in this order.
Step 0: Decide the destination first
Don't start migrating until you know where you're going. Each destination changes step 4 and step 7 significantly.
- Render: closest to old Heroku ergonomics, web service sleeps on free tier, Postgres is solid
- Railway: best DX for small projects, usage-based pricing surprises at scale, Postgres reliability has been variable
-
Fly.io: best if you need multi-region, requires a
fly.tomlfile - HostingGuru: managed PaaS, EU + US, AI monitoring built in, predictable pricing
- AWS / GCP: only if you have a real DevOps person on the team
- VPS + Coolify/Dokku: cheapest, you maintain the server
For the rest of this playbook I'll be platform-agnostic except where it matters.
Step 1: Inventory what's actually running
Before touching anything, list every dyno, every add-on, every config var, every scheduled job.
heroku ps --app yourapp # web + worker dynos
heroku addons --app yourapp # databases, redis, monitoring, etc.
heroku config --app yourapp # env vars (don't paste this in Slack)
heroku features --app yourapp # any legacy/labs features still on
heroku scheduler:jobs --app yourapp # if using Heroku Scheduler
Pipe these into a markdown file in your repo (MIGRATION.md). You will need to refer to it 6 times.
Step 2: Provision the destination
On the new platform, create everything that needs to exist before the cutover:
- The web service (don't deploy real code yet — a placeholder is fine)
- Workers if you have them
- Database (always Postgres for me; we'll cover the dump-restore in step 5)
- Redis if you have Sidekiq/BullMQ
- Object storage if you use S3-equivalent
- Any external API webhooks (you'll need to update their target URLs in step 7)
Goal at end of step 2: every "thing" exists on the new side, empty.
Step 3: Copy env vars carefully
This is where every migration gets a paper cut. Heroku's heroku config output looks like this:
DATABASE_URL: postgres://user:pass@host:5432/db?sslmode=require
REDIS_URL: redis://...
RAILS_MASTER_KEY: ...
STRIPE_SECRET_KEY: sk_live_...
Three things to watch:
- DATABASE_URL will need to change to the new DB's URL. Don't paste the Heroku one.
-
Watch the trailing
?sslmode=require— this is the #1 silent migration killer (more on this in Part 2). -
Heroku auto-generates
PORTfor you; on most platforms you do too, so don't manually copy it.
Step 4: Deploy code to the new platform — get "hello world" working
Push your code to the new platform. Don't migrate the database yet. Just confirm the platform can build your code and your /healthz endpoint returns 200.
If you have a deploy config file (render.yaml, fly.toml, hostingguru.yml, whatever), put it in your repo BEFORE the migration and merge it. Don't be discovering it works at 11pm.
Step 5: Database migration (the scary part)
Backup → restore. The commands are basically the same on every platform:
# From Heroku
heroku pg:backups:capture --app yourapp
heroku pg:backups:download --app yourapp --output /tmp/dump.tar
# To new platform (psql connection details from the new DB)
psql -h NEW_HOST -p NEW_PORT -U NEW_USER -d NEW_DB \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
pg_restore --verbose --no-owner --no-acl \
-h NEW_HOST -p NEW_PORT -U NEW_USER -d NEW_DB /tmp/dump.tar
Then verify with row counts, every single table:
SELECT 'users' AS table_name, COUNT(*) FROM users
UNION ALL SELECT 'posts', COUNT(*) FROM posts
UNION ALL SELECT 'orders', COUNT(*) FROM orders;
Compare to Heroku. If anything mismatches, stop, investigate, don't proceed.
Step 6: Put Heroku in maintenance mode + flip DNS
heroku maintenance:on --app yourapp
# users see a "we'll be back" page
Then update your DNS provider (Namecheap, OVH, whatever) to point at the new host. Set TTL low (300 seconds) before this if you remember — DNS propagation will go faster.
Step 7: Final dump + restore
Yes, dump the database again. Heroku has been writing data for the last few hours while you set up. Do a fresh capture, restore on the new side. Then row-count verify again.
Yes, this means downtime equal to the dump-restore time. For most apps under 5GB, this is 5–15 minutes. Schedule the migration window.
Step 8: Reroute external webhooks, then drop maintenance mode
Anything that calls into your app from outside needs its target URL updated to the new host:
- Stripe webhooks
- SendGrid event webhooks
- OAuth callback URLs (Google, GitHub, Slack)
- Custom integrations
- Cron jobs hitting your
/api/...endpoints from external services
Then heroku maintenance:off. Then watch your logs for 10 minutes straight. If the logs are quiet and your /healthz is green, you're done.
Part 2 — The 7 things that bit me
This is the part nobody warns you about. Every one of these has cost me at least 45 minutes of debugging at least once.
Bite 1: The heroku_ext schema doesn't exist anywhere else
Heroku installs Postgres extensions in a special heroku_ext schema (not public). When you pg_restore to a non-Heroku Postgres, the restore tries to install extensions in heroku_ext and fails:
pg_restore: error: could not execute query: ERROR: schema "heroku_ext" does not exist
The restore often appears to complete despite the error, but the extensions (uuid-ossp, unaccent, pg_trgm, etc.) aren't installed. Then your app boots and gets a function uuid_generate_v4() does not exist 500 error in production.
Fix: after restore, recreate the extensions manually:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "unaccent";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
List of likely-needed extensions: heroku pg:psql --app yourapp -c "SELECT extname FROM pg_extension;" before you migrate.
Bite 2: pg_stat_statements is enabled by default on Heroku, often not elsewhere
If you have any internal slow-query monitoring (e.g. PgHero, a custom admin dashboard), it queries pg_stat_statements. On the new platform this extension is often not enabled by default. Your slow-query dashboard silently shows zero queries.
Fix: enable it on the new platform. On Render/Railway/HostingGuru it's a one-line config flag. On a VPS with your own Postgres, edit postgresql.conf:
shared_preload_libraries = 'pg_stat_statements'
Then CREATE EXTENSION pg_stat_statements;.
Bite 3: Heroku's DATABASE_URL bakes in sslmode=require
Heroku's connection string format always includes ?sslmode=require. Most apps rely on this being implicit. When you move, some platforms don't add it by default, and your Rails / Django / Node app starts logging cryptic SSL errors at higher load (when the connection pool churns):
PG::ConnectionBad: SSL connection has been closed unexpectedly
Fix: explicitly add ?sslmode=require to your new DATABASE_URL. Or set PGSSLMODE=require as an env var. Diff the connection strings character-by-character before flipping traffic.
Bite 4: Scheduled jobs run on different time zones
Heroku Scheduler runs jobs in UTC. Some platforms default to UTC, some default to the platform's region timezone, some default to whatever timezone the cron entry doesn't specify.
If you have a 0 9 * * * job that ran at 9am UTC on Heroku, and you migrate to a platform that interprets it as 9am local-time-of-some-California-datacenter, your "daily report at 9am Paris time" now runs at 6pm.
Fix: always explicitly check the new platform's cron timezone before migrating. Most modern platforms (Render, Railway, HostingGuru) are UTC. AWS EventBridge defaults to UTC. Older or self-hosted setups vary.
Bite 5: Heroku's DYNO env var doesn't exist anywhere else
Heroku sets DYNO=web.1 or DYNO=worker.1 automatically. Some apps use this to determine "am I a web process or a worker?" so they can skip certain initialization. After migration, DYNO is unset, and your worker process tries to bind to a port (because the "am I web?" check defaults to true).
Fix: grep your codebase for process.env.DYNO, ENV["DYNO"], os.environ.get('DYNO'). Replace with explicit env vars you set per process (PROCESS_TYPE=web vs worker).
Bite 6: Heroku Postgres "follower" replicas don't survive
If you used Heroku Postgres followers (read replicas) for analytics queries, those don't migrate. The new platform may or may not have a managed replica option, and the connection string format is always different.
Fix: if you don't use the replica heavily, just point all queries at primary for the first week post-migration. Then add a replica on the new platform if needed. Don't try to bring the replica online during the migration itself.
Bite 7: Buildpacks vs Docker — your build will behave subtly differently
Heroku auto-detects your stack via buildpacks (e.g. heroku/python, heroku/ruby). Some new platforms also use buildpacks (Render uses Nixpacks, Railway uses Nixpacks). Some require a Dockerfile.
Even when both use buildpacks, the specific buildpack version differs. I've seen the same requirements.txt install Python 3.11.7 on Heroku and Python 3.12.1 on Render — and then a library breaks because Python 3.12 deprecated something.
Fix: pin your runtime version explicitly. For Python, that's a .python-version or runtime.txt. For Ruby, that's .ruby-version. For Node, that's "engines": { "node": "20.11.x" } in package.json.
The full migration timeline I quote clients
For a "small to medium" Rails or Django app (1 web service, 1 worker, 1 Postgres < 5GB, < 20 env vars):
- Step 0 (decide destination): 1–3 days (mostly waiting for client approval)
- Step 1–4 (prep): half a day of focused work
- Step 5 (DB migration dry-run): 1 hour
- Step 6–8 (cutover window): 30–60 minutes with the team on standby
Total: roughly 2–3 hours of execution time in a 1-week calendar window. The week is for sanity, not because the work takes that long.
If anyone quotes you "we'll migrate Heroku in 30 minutes," they haven't done it. There's always something you didn't expect — most often one of the 7 bites above.
What I'd do if I were migrating today
-
Run the inventory (
heroku ps,addons,config,scheduler:jobs) and put it in a markdown file today. You'll need it whether you migrate this month or in 6 months. - Pick a destination that fits your team's operational maturity. If you don't have a DevOps person, pick a managed PaaS. If you have someone who already runs Kubernetes, you have more options.
- Pin your runtime versions in your repo before the migration. This is the cheapest insurance policy.
- Do one dry run of the database migration to a throwaway DB on the new platform. Verify row counts. Then do the real one later.
- Schedule the cutover for a Tuesday or Wednesday morning, not a Friday. If something breaks, you want a full work week to fix it.
A note on the platform I run
If you're picking a destination and you want one that ships with the operational stuff (Telegram alerts, log-pattern detection, EU data center, predictable pricing), HostingGuru is built around exactly that. Pro tier is €35/mo for 10 services with workers and on-demand scripts included (the same primitives Heroku had with worker: dynos and Heroku Scheduler). Free tier never sleeps.
If you pick another destination, the playbook above still applies — just translate "step 4: deploy code" to whatever that platform's deploy flow is.
Closing
Heroku is in maintenance mode, not gone. Your app will keep working there. But every month you delay migrating, you're betting that nothing breaks on a platform that's stopped investing in fixing things. That's not crazy in 2026. It just gets less crazy with time, not more.
Whenever you do migrate, the order of operations matters more than which destination you pick. Pick the order. Execute Tuesday morning. Watch the logs for 10 minutes. Then take Wednesday off — you've earned it.
What's the dumbest thing that broke during your migration? I'm collecting these for v2 of this post.
Previous posts in this series:
2. I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.
3. Your AI app is silently burning $2,000/month and you don't know it.
4. Telegram alerts for any production app — a 5-minute setup.
Top comments (0)