A production Rails site went from broken-on-Heroku to self-hosted on a Linode box in three days, across four tracked issues, with the Heroku provider deleted from Terraform at the end. The before/after bill tells the story in one table — then this post walks each cutover step the way the team actually ran it, including the week-one cost of their boldest decision: no fallback.
| Before (Heroku) | After (Linode + Kamal) | |
|---|---|---|
| Compute | $7/mo Basic dyno (or $5 Eco, sleeps) | $0 marginal on an existing shared box; $5–12/mo for a fresh Nanode/2GB |
| Database | $5/mo Postgres Mini / Essential-0 | $0 — Postgres 16 as a Kamal accessory on the same host |
| CDN | CloudFront in front (metered) | $0 — retired; Route53 straight to the box |
| Deploys | git push heroku | $0 — Kamal via GitHub Actions after green main |
| Typical floor | ~$12/mo | $0–12/mo |
At bigger tiers the gap widens: a Standard-1X dyno ($25) plus Standard-0 Postgres ($50) is $75/mo before add-ons, while the Linode 4GB box that comfortably replaces both is $24/mo. But the money was never the forcing function here. The forcing function was that Heroku had already broken — and Salesforce had already told everyone where the platform is headed.
Why this migration happened now
On February 6, 2026, Heroku's chief product officer announced the platform was transitioning to a "sustaining engineering model" — stability and security patches, no new features, no new Enterprise contracts for new customers. The press read it as the beginning of the end: The Register's headline was "Salesforce puts Heroku out to PaaSture," and migration guides proliferated within weeks.
For one team, the abstract platform risk turned concrete five months later. Their site, jamesebentier.com, "was deployed through Heroku but [had] not been working due to the database service no longer existing." A dead Heroku Postgres with no path worth rebuilding on a frozen platform is about as clear a migration trigger as it gets. On July 15, 2026, they opened epic #1148: Migrate from Heroku to Linode + Kamal. By July 18, all four child issues were closed and the maintainer confirmed: "Production is now served from Linode via Kamal, public DNS points directly at the host, and green main builds deploy automatically."
Three days from epic to done. The speed is worth studying, because it came from decisions, not heroics: a short design doc locked the controversial choices up front, and the work was split into four thin vertical slices with explicit ordering. Here is each slice.
The locked decisions that made it fast
Before any code, the team wrote a one-page design doc and approved it in conversation. Five rows did most of the work:
| Topic | Choice |
|---|---|
| Public traffic | Direct to Linode via Route53 A/AAAA; CloudFront retired from the live path |
| Postgres | Kamal accessory on the same host; fresh DB only — no restore, no data migration |
| Host provisioning | Manual, outside the tracked work; agents assume a host IP/user |
| Manual ops | Human applies secrets, first kamal setup/deploy, DNS apply, GitHub deploy secrets |
| Decomposition | Thin vertical slices, one issue each |
Two of these deserve emphasis. No DB restore is the decision that made a three-day migration possible — and the one that limits how literally you can copy this playbook. The Heroku database was already gone, so there was nothing to migrate; the app was wired for db:prepare on boot against a fresh Postgres. If your Heroku Postgres is alive and holds data you need, add a pg_dump/pg_restore (or logical replication for zero-downtime) step that this team never had to run.
No fallback is the decision with the sharpest edge: keeping Heroku or CloudFront warm after cutover was explicitly out of scope. More on what that costs below.
Step 1: Kamal baseline + Postgres accessory (#1160)
Issue #1160 was the only slice marked Ready first: a Kamal config for a single Linode host, Postgres as an accessory, the app wired for a fresh DB, and a first-deploy runbook. Everything else in the epic depended on it.
The resulting config/deploy.yml is a clean reference for a single-host Rails deploy. The registry is GitHub Container Registry (ghcr.io), chosen deliberately so the later CI job could reuse GITHUB_TOKEN auth for free — the container-registry swap, from Heroku's Container Registry to ghcr.io, fell out of that one block. TLS terminates in Kamal's own proxy (kamal-proxy), which requests a Let's Encrypt certificate for the public hostname; the app container just serves port 3000 behind it. Secrets (RAILS_MASTER_KEY, DATABASE_URL, the Postgres password) live in .kamal/secrets as variable references, never committed values, with host and registry credentials supplied via environment.
The Postgres accessory is configured with no published port: the app reaches it over Kamal's private Docker network at jamesebentier-site-postgres:5432, so port 5432 never opens on the host's public interface. This is the single most important line in the file from a security standpoint, and it replaces Heroku Postgres's managed network isolation with a Docker-network boundary you now own.
The runbook's best section is the one you would never get from a greenfield tutorial: the target Linode was already running an unrelated app (bot.biti.dev), making it a multi-tenant box. So the runbook forbids the default path — a blind kamal setup can reconfigure the Docker daemon and proxy container the other app depends on — and instead boots the accessory and deploys the app as separate steps (kamal accessory boot postgres + kamal deploy).
It also wires ADDITIONAL_ALLOWED_HOSTS so Rails answers on both the domain and the bare Linode IP, letting the operator verify the app over HTTP(S) on the box before DNS moves. That IP-first verification is the release-command mapping in miniature: Heroku's release phase becomes db:prepare on boot plus a health check against the host, with no platform release step involved.
Done-when: from a clean clone plus filled secrets plus the Linode, Kamal is the deploy path and the app answers on the host. It closed the same day it was filed.
Step 2: DNS cutover in Terraform (#1161)
Issue #1161 moved the public hostname. The end state, codified in terraform/route53.tf, is almost comically small: an A record for the apex at TTL 300 pointing at the Linode IP, and CloudFront deleted from the live path.
Two details matter. First, the issue explicitly blesses cutting DNS by hand as soon as the host serves traffic — "because Heroku is already down" — and then codifying that state in Terraform after the fact. When the old platform is already serving errors, the usual cutover choreography (lower TTLs, wait, flip, watch) collapses: there is no working state to preserve, only a broken state to replace. The Terraform change becomes a record of reality rather than the mechanism of change. That is a legitimate shortcut only because the precondition held; with a live Heroku app you would still pre-lower the TTL and flip deliberately.
Second, retiring CloudFront is a real architectural change, not just a deletion. The team traded away edge caching, edge TLS, and volumetric-DDoS absorption in exchange for one fewer managed dependency and one fewer bill. For a personal site behind a 300-second TTL, that is a defensible trade. For anything with meaningful traffic, the honest replacement is either keeping a CDN in front of the Linode or accepting origin-direct exposure with Kamal's proxy as your only edge. The design doc treats the CDN as load-bearing enough to sequence around — Heroku retirement had to wait for this step — but not load-bearing enough to keep.
Step 3: CI deploys after green main (#1162)
Issue #1162 replaced git push heroku with automation: merge to green main, and Kamal deploys. The Deploy workflow triggers on workflow_run — it runs only after the CI workflow completes successfully on main — with a single-deploy concurrency group, a 30-minute timeout, and a docs-only-change detector that skips deploys for documentation commits (failing open to a deploy if the changeset can't be determined).
Authentication is the whole job of the workflow's setup steps: an SSH private key and known-hosts from GitHub secrets for the Kamal-over-SSH connection, plus a ghcr.io login reusing the same registry the deploy config chose in Step 1. PRs never deploy; deploy failures surface in Actions. The acceptance criterion — "merge to green main triggers an automated Kamal deploy" — was confirmed working when the epic closed.
Note the dependency discipline: this slice stayed in Backlog until the Kamal baseline was mergeable, but was explicitly independent of DNS timing — CI can deploy to the host's IP before or after the public hostname moves. That independence is what let Steps 2 and 3 land on the same day.
Step 4: Retire Heroku from Terraform (#1163)
Issue #1163 is the step most migrations defer forever: deleting the Heroku provider, the app, the add-ons, and the config associations from Terraform, plus dropping Heroku-only assumptions in-repo "only as needed" for a clean teardown. It was gated on the public cutover being complete so Heroku and CloudFront were provably not load-bearing, and it closed within hours of the DNS step.
This is where the no-fallback decision bites. The week DNS flips, "no fallback" costs you three specific things. First, rollback becomes a rebuild: if the Linode deploy has a bad evening, there is no warm Heroku app to repoint DNS at — recovery means fixing forward on the box or re-provisioning from scratch, on the clock. Second, TLS risk concentrates: Kamal-proxy's Let's Encrypt issuance has to succeed on first contact with the real hostname, with no CloudFront edge cert to hide behind while you debug. Third, observability resets to zero: Heroku's metrics, logging, and release history stop mattering the day you leave, and whatever replaces them (even kamal app logs plus Linode's graphs) is unfamiliar exactly when incidents are most likely.
Whether that price is right depends on the blast radius. For a personal site whose Heroku backend was already dead, keeping a fallback would have meant paying to resurrect a platform they'd already decided to leave — pure cost, no insurance value. For a revenue-bearing app, the same discipline would be reckless without a staged cutover and a warm standby. The lesson isn't "never keep a fallback"; it's that the fallback decision belongs in the design doc's decision table, priced explicitly, not discovered at 2 a.m.
What this playbook doesn't cover
An honest accounting of what the $0–12/mo after-column omits:
- A single host is a single point of failure. Kamal supports multiple hosts, but this deploy is one Linode. Host maintenance, host failure, or a bad accessory upgrade takes the site down. Heroku's dyno restarts and Postgres failover were invisible insurance; the replacement is either a second machine or an accepted RTO measured in however long a rebuild takes.
- Postgres backups are yours now. A Kamal accessory is a container with a data volume, not a managed database.
pg_dumpon a cron to object storage (or Linode's backup service for the whole box) is a separate piece of work this epic never needed to include — but the first production incident will wish it had. - Asset-pipeline un-Heroku-fication was a non-event here, and that's Rails-specific luck. The app's Dockerfile already ran
assets:precompileat build time, so leaving Heroku's Ruby buildpack changed nothing about asset serving. Apps relying on buildpack-injected behavior (or Heroku-specific add-ons for Redis, scheduler, or metrics) each add a migration step this team didn't have. - The margin math assumes comfort with SSH. Kamal's control plane is your laptop plus a key. Teams that valued Heroku's dashboard-and-click-ops model inherit an on-call surface of Docker, systemd, and UFW whether they budgeted for it or not.
None of this invalidates the migration — the epic's own framing is "restore production on owned hosting," and it did exactly that. But the before/after bill is only the whole story if your time is free and your uptime bar is "best effort." Price the ops burden alongside the invoice.
The takeaway for teams still on Heroku
Salesforce's February 2026 announcement didn't shut Heroku down; it froze it. Frozen platforms don't fail all at once — they fail the way this team's database did, one managed service at a time, with no new platform capabilities arriving to replace what rots. If you're still on Heroku, the question isn't whether to plan an exit but which exit fits your scale: Kamal on a VPS for the single-app case this epic demonstrates, a managed PaaS with a future for teams that want to stay click-ops, or a self-hosted platform when one box stops being enough.
What generalizes from this migration regardless of destination: lock the scary decisions (data, fallback, DNS sequencing) in writing before the first commit; slice the work so each step has its own done-when; verify on the new backend by IP before moving the hostname; and delete the old provider from Terraform while the cutover is fresh, because "temporary" dual-running has a way of becoming permanent infrastructure — and a permanent second bill.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



