What We Check Before Migrating Off Heroku
We recently moved a client’s Ruby on Rails app off Heroku: a Rails API with Sidekiq workers and several services around it. Before we changed anything, we spent about a week on discovery. We had a kickoff call with the client’s team on a Monday, and then two of us paired for the rest of the week to map the infrastructure and draft a migration plan.
Nothing about that app followed one pattern. Its services were deployed in different ways, and some of the things that mattered most for the migration, like the tools outside Heroku that read from the database and the service that promoted code to production, didn’t show up in the Heroku dashboard at all.
In this article, you will learn what we inventory before moving an app off Heroku, where to look beyond what Heroku shows you, and what our own discovery missed. Almost none of it depends on where you are going: it applies whether your target is Render, Fly.io , AWS, or something else.
Start with the people, then verify
We started with a kickoff call where the client’s team gave us an overview of the pieces: the main app, the services around it, and the tools they relied on. It was a high-level conversation, and after it the details were up to us. We went through each repo and each dyno on Heroku ourselves to find out what actually runs where. People describe what they think about every day, and the pieces they don’t think about are often the ones that break a migration.
Some of what you need to know, Heroku can’t tell you. We worked out part of it ourselves and asked the client’s team about the rest. Before planning any downtime, it helps to know who the customers are, where they are located, and whether there are known peak events, so you can pick a window that will hurt the least.
Who wrote what is something you can mostly find out on your own: we looked through the commit history of the repos to see who had worked on which part, so we knew who to send questions to later. Why the architecture looks the way it does is a question only the team can answer. Some choices are constraints you need to keep, and others are leftovers you can drop, and you want to know which is which before you rebuild them somewhere else.
It’s also worth asking whether the app has a maintenance page, and which jobs run on a schedule on staging and on production, since those keep running during a cutover unless someone pauses or moves them.
A few third-party tools also came up, and it wasn’t obvious at first how each one connected to the app. We ended up sorting every tool that came up into three groups:
- Tools that run on Heroku, which we move.
- Tools that run elsewhere but depend on something we move, which the client’s team updates.
- Tools the migration doesn’t touch.
The second group needs the most attention, because those tools stop working if nobody updates them after the move. For us, that was an ETL sync and an AI code-review bot, both reading from the database. The client’s team wanted to update them themselves, which was fine, but we still had to find out how each one connected so everyone knew who owned what before cutover day.
What Heroku tells you
Once we knew what to look for, we went through every app in the Heroku dashboard, on both staging and production: the add-ons attached to each one, its config vars, and its dynos with their process types and sizes.
During discovery, we only listed the config vars. They turned into an audit later, when we were getting ready to move each service. For each variable, we searched the codebase to see whether anything still used it, and confirmed with the client’s team when the code didn’t make it clear. Only the variables that were still in use had to be set on the new platform. Compare them between staging and production while you’re at it, since some may be set differently in each environment.
We did the same with the dynos: we looked at their types and sizes during discovery, and confirmed them when each service moved, so that it would have roughly the same resources on the new platform as it had on Heroku.
If you have more than a couple of apps, or you’d rather keep a copy of what you found, the Heroku CLI shows the same information. heroku addons lists what is attached to an app, heroku config lists its config vars, heroku ps shows its process types with the size and number of dynos for each one, and heroku pg:info shows the Postgres version, plan, and data size:
heroku addons -a your-app-production
heroku config -a your-app-production
heroku ps -a your-app-production
heroku pg:info -a your-app-production
Looping over heroku pg:info is also a quick way to check the Postgres version of every app you have .
Config vars hold secrets, so be careful about where that output ends up. If you only need the names, for a checklist or to compare environments, you can print the keys and diff them:
diff <(heroku config --json -a your-app-staging | jq -r 'keys[]' | sort) \
<(heroku config --json -a your-app-production | jq -r 'keys[]' | sort)
Every component is its own migration
Heroku shows you what runs there, but not how each piece got there. On our project, the services weren’t all deployed the same way. The image proxy, for example, ran straight from an upstream container image, with only configuration on top, while other services had build steps of their own. On Heroku they all look like any other process type, and nothing there tells you which one depends on someone else’s image and which one depends on a build you will have to reproduce.
That difference decides what the move looks like. For a service that runs from an upstream image, you need to know where the image comes from and whether the new platform can pull it. For a service with its own build, whether that’s a Dockerfile, a buildpack, or a shell script, you need to read it, because it is often the only place where the build is written down.
Reading the code is part of discovery, so we went through each repo. The YAML config files described the services they deployed, and they answered many of our questions about how each one was built. When something in them didn’t make sense, that became a question for the client’s team.
Treat each component as a small migration of its own, with its own build step, config, and move order, instead of assuming that what works for the main app will work for everything around it.
Add-ons, workers, and scheduled jobs
For most add-ons, moving means provisioning the equivalent service on the new platform and changing a URL in the config vars. The ones to look for are add-ons with no direct equivalent. Ours was a Memcached add-on, which many platforms don’t offer as a managed service, so we replaced it with a Redis cache.
During a migration, there is usually a period where both platforms can run the app. Web processes and workers can run on both for a while, as long as they talk to the same Redis, but scheduled jobs should run on exactly one platform at a time. List everything that runs on a schedule, whether it’s Heroku Scheduler, a clock process in the Procfile, or a gem like sidekiq-cron, and decide where each one runs during the transition. Check, too, which jobs must never run twice at once and whether anything in the code prevents it, since two copies of a job that calls a rate-limited API can use up a quota that one was designed to stay under.
Deploys, DNS, and alerting
Check how code reaches production. The Heroku pipeline showed a staging app and a production app, but promotion between them was handled outside Heroku, by a third-party automation that promoted a commit from staging after a set period. The client’s team also relied on review apps, so those went on our list of things to rebuild.
Find out who controls DNS. The client’s domains were at a third-party registrar, pointing at Heroku’s DNS targets, and the client’s team made the change at cutover. Lower the TTL on every record you’ll change, and do it well ahead of the window: at least as far ahead as the record’s current TTL. Resolvers can keep serving the old value for that long, so with a 24-hour TTL, you’d lower it at least a day before.
Finally, look at alerting and traffic. The client’s alerts went to Slack and PagerDuty, so decide which triggers to pause during each window and who turns them back on. We used the app’s tracing and database metrics to find the hours with the least traffic, then confirmed those traffic cycles with the client’s team.
What discovery missed
A week of discovery didn’t find everything. Two things got past us, and for each of them there was a check that would have caught it earlier.
Extensions in a different schema in production
The first one was in the database. Between August 2022 and mid-2023, Heroku installed Postgres extensions into its own heroku_ext schema , and extensions created during that time are still there unless someone asked Heroku Support to move them. On this app, production’s hstore, cube, and earthdistance extensions lived in heroku_ext, while staging’s lived in public, and nobody knew the two environments differed. The staging rehearsal went smoothly because staging had nothing in heroku_ext, but once we moved the production database, the app failed at runtime with this error:
permission denied for schema heroku_ext
What would have caught it is comparing the two databases before moving either one. In psql, \dx lists the installed extensions with their versions and schemas, and \dn+ lists the schemas with their access privileges. Running both on staging and on production and comparing the output would have shown the difference in a few minutes.
Redis connections from Heroku timing out
The second one was Redis. We initially planned to move Redis to the new platform during the database downtime, while the web app was still running on Heroku. That meant the app had to reach the new Redis instances from Heroku over external connections, and it errored while waiting on the AUTH response, because the default timeout is one second.
We were moving to Render, and the cause was specific to how Render Key Value , its Redis-compatible service, handles connections from outside Render. Key Value only accepts internal connections by default. Once you allow external ones, each new connection has to log in with AUTH before it can do anything else. Normal commands were fast, but AUTH took between one and four seconds, longer than the client’s one-second timeout, and the app kept just under 400 connections open at any given time. The client treats AUTH like any other command, so the only fix on the app’s side was a longer timeout for every command, which would also make real Redis problems slower to surface. Moving only part of the data wasn’t an option either: Redis held the Sidekiq job state and the GraphQL subscriptions, and every instance of the app had to use the same Redis.
So we changed the plan: move the full app first, keep using Heroku’s Redis for a bit after the move, and then take a short downtime to move Redis as well. The check that would have caught this earlier is connecting from the app’s own Redis client, with its own config, over the network path it will actually use during the transition, before you plan a window around it.
What discovery should leave you with
By the end of discovery, you want a written answer for every component: how it runs today, what it connects to, and what replaces it on the new platform. A table is the simplest way to keep that in one place. For the app we moved, it looks something like this:
| Component | How it runs today | Connected to | Replacement on the new platform |
|---|---|---|---|
| Postgres | Heroku Postgres | web, workers, ETL sync | managed Postgres |
| Web (Rails API) | Heroku buildpack | DNS, alerting | container image built in CI |
| Workers and cron | Heroku buildpack | Redis | same image as web |
| Redis | two attached add-ons | web, workers | managed Redis |
| Cache | Memcached add-on | web | Redis cache |
| Image proxy | upstream container image | web | the same upstream image |
| ETL sync | owned by the client’s team | Postgres | updated by the client’s team |
Once we had answers for every component, we were free to propose the order of the migration, and the table pointed to it. We moved both databases first, staging and then production. Next came the supporting services on staging, the telemetry pieces and the image proxy, and then the staging web app and workers together with Redis.
Production followed a similar pattern: the telemetry pieces and the image proxy first, then the web app and workers, which we brought up on Render independently of what was running on Heroku while DNS still pointed there and the app kept using Heroku’s Redis. The last step was a second cutover, where the client’s team changed DNS and we migrated Redis.
That order held up. The one time the order itself caused a problem was when we tried to move Redis early, during the database downtime.
Conclusion
In this article, we went through what we inventory before moving an app off Heroku. Heroku’s dashboard and CLI only cover part of that inventory. The things that mattered most on our migration lived in other repos, at the DNS registrar, in tools outside Heroku that read from the database, and in the workflow that promoted code to production. Sorting out early who owns each of those, and giving every component a row in the table, is what let us propose the migration order with confidence.
Not every app needs a week. If yours is a web process and a database, the move can take about an hour . Even our week of discovery missed two things. A staging rehearsal catches a lot, but only for what staging and production actually share, so compare the two before you trust the rehearsal.
Planning a move off Heroku? Take a look at how we approach Heroku migrations , and send us a message !