Moving a Rails app off Heroku without rewriting it
· Railyard
The reason most teams stay on a managed platform longer than they meant to is not the price. It’s that the migration looks like a project with no clear end. This post is the shortest path we know of, in the order that keeps the app running at every step.
Step 1: deploy it unchanged, with buildpacks
Do not start by writing a Dockerfile. Start by proving the application runs somewhere else at all.
Cloud Native Buildpacks read your repository the same way Heroku’s build does, using prebuilt runtime layers rather than compiling a language from source. In practice a standard Rails or Django app builds without a single file being changed.
If it boots, you have already proven the hard part: nothing about your application actually depended on the platform.
Step 2: move the data services
Your app almost certainly needs Postgres and Redis. Provision them as addons, and Railyard injects the connection strings into the environment the same way you’re used to.
Two things to check:
- Extensions. If you use
pgvector, PostGIS or similar, make sure the provisioned Postgres has them. Ours does for pgvector. - The dump. Restore a real production dump into the new database before cutover, and time it. This is the step teams underestimate.
Step 3: declare your processes
If your app has a Procfile, Railyard reads it — web process, worker process, and the
release: hook that runs migrations before anything serves traffic. That last one matters
more than it looks: it is what keeps a migration from running against a version of the code
that has already started taking requests.
Step 4: switch to a Dockerfile, if you want to
Once it’s running, a Dockerfile buys you reproducibility and faster warm builds. It is not required, and it is much easier to write for an app you can already deploy than for one you cannot.
With a registry-backed layer cache, warm rebuilds of a large Rails app land in around a minute and a half.
What actually breaks
From doing this repeatedly, the recurring problems are:
- Apps that bring their own infrastructure. Some applications boot their entire
environment during
assets:precompileand need a search cluster to exist. That fails on any platform without the addon — it is not a migration problem, it is an app requirement you had forgotten about. - Build memory on small boxes. A 2 GB server compiling native extensions can run out of memory. Either build on a dedicated builder, or cap the build’s memory so it fails cleanly instead of taking the machine with it.
- Assumed ephemeral disk. Anything writing to local disk needs a persistent volume, or the next deploy quietly discards it.
What you get at the end
Your app runs on servers in a region you chose, at your provider’s price with no markup, and if you ever stop paying for the control plane the containers keep serving traffic. The workflow — push, watch the build, roll back if it’s wrong — stays the same as the one you left.