Skip to main content

Heroku to Kubernetes in a Weekend: The $140-to-€11 Migration Playbook

11 min readDora NodaDora Noda
Share
On this page

On February 6, 2026, Salesforce moved Heroku into a sustaining engineering model: security patches and uptime, but no new features and no new Enterprise contracts. The platform still runs — your dynos did not stop — but the roadmap is now a status page. For teams paying a real monthly bill, the question stopped being "should we leave" and became "what does leaving actually take."

This post answers that second question with a concrete weekend runbook: export a Heroku web + worker + Postgres + Redis stack on Friday evening, stand up the equivalent on a Cluster API-managed fleet on Saturday, move the data, and cut DNS over on Sunday. No audit checklist, no cost-of-waiting lecture — commands, sequence, and both invoices.

TL;DR — the two bills for a typical production stack:

Heroku (before)Owned fleet (after)
Web2× Standard-1X dynos, $50Bin-packed on workers, $0 marginal
Worker1× Standard-1X dyno, $25Bin-packed on workers, $0 marginal
PostgresStandard-0, $50Self-hosted on a worker volume, $0 marginal
RedisMini, $15Self-hosted alongside, $0 marginal
Total$140/month ($1,680/year)€11.37/month net (~€137/year)

The after number is three Hetzner CX22 machines (2 vCPU / 4 GB RAM / 40 GB NVMe / 20 TB traffic each at €3.79/month net) running one control-plane node and two workers under Cluster API Provider Hetzner. That is an 11× cost reduction on infrastructure — and the rest of this post shows the weekend that earns it, plus the honest ops ledger the infrastructure line hides.


Friday Evening: The Inventory (About an Hour)

Every failed migration I have read about failed the same way: something nobody wrote down turned out to be load-bearing. A Scheduler job that ran the billing reconciliation. A config var set three years ago that the code reads but the docs never mention. Friday evening exists to make that impossible. Touch nothing; write everything down.

Run these from your laptop with the Heroku CLI authenticated:

bash
# 1. Every process the app runs
heroku ps -a "$APP"
 
# 2. Every config var, shell-escaped and ready to re-import
heroku config -a "$APP" -s > heroku-config.env
 
# 3. Every add-on and its plan (Postgres, Redis, logging, monitoring)
heroku addons -a "$APP"
 
# 4. Scheduler jobs (dashboard only — screenshot or transcribe each one)
heroku addons:open scheduler -a "$APP"
 
# 5. Postgres version, plan, and size — the restore target must match major version
heroku pg:info -a "$APP"
 
# 6. Custom domains and TLS state
heroku domains -a "$APP"
heroku certs -a "$APP"

Then do the one thing that cannot be rushed later: lower your DNS TTLs to 300 seconds tonight. Sunday's cutover is only fast if resolvers stop caching the old records. If your apex domain uses Heroku's ALIAS/ANAME support, note that too — the new fleet will want plain A/AAAA records to its ingress Floating IP.

What you should have by bedtime: the Procfile's process list, a complete env file, the add-on inventory with plans, the Scheduler job list, the Postgres major version, and a DNS plan. If any Scheduler job does something you cannot explain, that is Friday's discovery doing its job — decode it before Sunday, not during.


Saturday Morning: Stand Up the Landing Zone

The TODO spec for this post says the equivalent lands on a Cluster API-managed fleet, so that is the target: a small CAPI workload cluster on Hetzner, provisioned with clusterctl and Cluster API Provider Hetzner (CAPH). One control-plane node and two workers, all CX22s, is enough for the typical $140 stack with headroom — Kubernetes bin-packs the web, worker, Postgres, and Redis pods across the two workers the way Heroku never could, because Heroku bills per dyno while Kubernetes bills per machine.

The shape of the work, compressed:

bash
# Management bootstrap (a laptop or a throwaway VM is fine)
clusterctl init --infrastructure hetzner
 
# Workload cluster: 1 control-plane + 2 workers, all CX22
clusterctl generate cluster heroku-exit \
  --infrastructure hetzner \
  --control-plane-machine-count 1 \
  --worker-machine-count 2 \
  | kubectl apply -f -
 
# When the nodes are Ready, install the PaaS plumbing
kubectl apply -f ingress-nginx.yaml   # ingress + Floating IP
kubectl apply -f cert-manager.yaml    # automated TLS, replaces Heroku ACM

Honest footnote: if your team has never operated Cluster API, Saturday morning is a lot to learn alongside everything else. The acceptable fallback is a single-node k3s install on one CX22 for the cutover weekend, then a planned move onto the CAPI fleet the following week. What you must not do is treat the single node as the final architecture without saying so — the whole point of the fleet target is that adding a second machine later is a MachineDeployment replica change, not a re-platform. Either way, end Saturday morning with a cluster that answers kubectl get nodes with Ready machines and an ingress controller holding a public IP you can point staging DNS at.


Saturday Midday: Containerize Without Rewriting

Here is the good news: you probably do not need to write a Dockerfile. Heroku's buildpacks have a direct descendant in Cloud Native Buildpacks (CNB), a CNCF project — and in September 2026 Heroku itself added CNB support to Cedar-generation apps, which tells you how fully the ecosystem has converged on this lineage. The same pack CLI and Paketo builders that compile a Heroku-style app locally produce an OCI image that runs on any Kubernetes cluster:

bash
# Build the web image exactly the way a buildpack would
pack build "registry.example.com/$APP-web:latest" \
  --builder paketobuildpacks/builder-jammy-full \
  --path .
 
# Same for the worker if it needs different processes
pack build "registry.example.com/$APP-worker:latest" \
  --builder paketobuildpacks/builder-jammy-full \
  --path .

Push both images to whatever registry your fleet trusts, then translate the Procfile into Kubernetes objects. The mapping is nearly mechanical:

Procfile entryKubernetes objectNotes
web: bundle exec puma ...Deployment + Service + Ingress2 replicas reproduces the 2-dyno setup
worker: bundle exec sidekiqDeployment (no Service)1 replica; scale by queue depth later
release: bundle exec rails db:migrateHelm hook or pre-deploy JobRuns once per deploy, like Heroku releases
Scheduler: rake billing:nightlyCronJob10-minute precision limit is gone
Config varsSecret + ConfigMapImport Friday's heroku-config.env

Two gotchas that bite every first-timer. First, Heroku injects PORT and expects the web process to bind to it; in your manifests, set PORT explicitly (or drop the Heroku-ism and bind a fixed port) so the container does not crash-loop waiting for an env var that only exists on Cedar. Second, Heroku's filesystem is ephemeral, so if the app writes uploads to disk, that already had to go somewhere external (S3 or equivalent) — verify the bucket credentials made it into Friday's env export, because "works on Heroku, loses files on Kubernetes" is always this.

For teams that want the Heroku push experience back rather than hand-running pack, kpack runs CNB builds as Kubernetes-native resources — git push triggers an Image build in-cluster. That is a week-two improvement, not a cutover-weekend requirement. Saturday's bar is images that boot.


Saturday Afternoon: Move the Data

Data is the only part of the weekend that cannot be hurried, so start it early and verify twice. Postgres first, Redis second.

For Postgres, capture a fresh backup from Heroku, download it, and restore into your in-cluster Postgres with ownership and ACLs stripped — the Heroku roles do not exist on your cluster, and carrying them over is the single most common restore failure:

bash
# Fresh backup, then download it locally
heroku pg:backups:capture -a "$APP"
heroku pg:backups:download -a "$APP"   # writes latest.dump
 
# Restore into the new database (major version must match Friday's pg:info)
pg_restore --verbose --clean --no-acl --no-owner \
  -h "$NEW_PGHOST" -U postgres -d "$NEW_DB" latest.dump

Then verify before you celebrate. Row counts on the three biggest tables, plus a check that the app's own migrations see a current schema:

sql
SELECT 'users' AS t, count(*) FROM users
UNION ALL SELECT 'orders', count(*) FROM orders
UNION ALL SELECT 'events', count(*) FROM events;

Compare against the same queries on Heroku (heroku pg:psql -a "$APP" -c "..."). If they match and the app boots against the new database in staging, Postgres is done. Do a full practice restore on Saturday even though you will redo it on Sunday — the practice run is where you discover the extension (pg_trgm, postgis) you forgot to install on the target.

Redis needs a decision, not just a command. Ask one question: is it a cache or is it data? If it is a fragment cache, a rate-limit bucket, or a session store with cookie fallback — flush it and let Sunday's cutover start cold. If it holds Sidekiq/Celery queues with jobs you cannot afford to lose, or business state with no other copy, migrate the keys: redis-cli --rdb dump.rdb against Heroku's instance (credentials via heroku redis:credentials) and import into your in-cluster Redis, or copy key-by-key for small datasets. Most teams discover their Redis is 95% cache and one queue worth saving — save the queue, drop the cache, and document which is which.


Sunday: Cut Over and Keep the Parachute

Sunday has an order, and the order matters. Do it in this sequence:

  1. Deploy everything to staging hostnames on the new fleet and run your smoke tests: sign up, log in, pay, hit the worker path end to end. Fix what breaks while Heroku still serves production.
  2. Freeze writes. Put the Heroku app in maintenance mode (heroku maintenance:on -a "$APP") so no write lands after your final backup. Announce the window; for most apps this is 15–60 minutes.
  3. Re-run the final Postgres capture and restore, then re-check row counts. This is the delta since Saturday's practice run — it should be small and fast.
  4. Flip DNS to the fleet's ingress Floating IP. Friday's 300-second TTL means most traffic moves within minutes; watch both Heroku's router logs and the new ingress during the transition.
  5. Verify in production, then lift maintenance mode on Heroku — but do not delete the app.
  6. Keep the Heroku app for 7 days as instant rollback. If something surfaces on Tuesday, the rollback is a DNS flip back, not a rebuild. After a clean week, scale the dynos to zero for one more week, then delete.

The most common Sunday failure is TLS: cert-manager needs to issue for your domains, and HTTP-01 challenges need the DNS already pointing at the fleet. Order the cutover so certificates are issued against staging-verified config first, or use DNS-01 challenges that do not depend on the flip. The second most common is the Scheduler job nobody transcribed on Friday — which is why Friday exists.


The Honest After-Bill

The €11.37 fleet number is real, but it is not the whole cost. Owning machines means owning work Heroku bundled into the dyno. Here is the full ledger, with sensitivity across three Heroku stack sizes — note the after column barely moves, because that is the entire economic argument:

Heroku stackBefore (Heroku/mo)After (fleet/mo)Multiple
Small: Basic web $7 + Postgres Standard-0 $50$57€11.37 (~$12.40)~4.6×
Typical: 2× Std-1X web $50 + worker $25 + PG Std-0 $50 + Redis Mini $15$140€11.37 (~$12.40)~11×
Large: Perf-M web $250 + Std-2X worker $50 + PG Std-0 $50 + Redis Premium-0 $60$410€11.37–€15.16 (~$12–$17)~26×

(The large stack fits the same three nodes unless it is genuinely CPU-bound, in which case one more CX22 at €3.79 closes it. Heroku-side USD vs Hetzner-side EUR uses roughly $1.09/€ — the multiple survives any plausible exchange rate.)

Now the ops ledger, priced in engineering hours rather than euros:

  • Postgres backups and tested restores (pgBackRest or WAL-G to object storage): ~1 hour/month once automated, plus one restore drill per quarter. Heroku's continuous backup was invisible; yours must be scheduled and verified.
  • TLS renewal: ~0 hours with cert-manager, ~1 panicked hour the first time you forget to monitor it. Monitor it.
  • Node patching and Kubernetes upgrades: ~1–2 hours/month on a CAPI fleet (roll the MachineDeployment, watch it converge). This is the line item Cluster API exists to shrink.
  • Log shipping and basic alerting: ~2 hours to set up (Loki or equivalent + uptime checks), minutes per month after.

Call it half a day a month of steady-state ops for the typical stack, against $127/month ($1,524/year) of infrastructure savings — and the savings compound with every additional app, because the second app on Heroku is another $57–$140 while the second app on the fleet is another namespace at $0 marginal cost. Published migration reports land in the same band: one Heroku-to-Kubernetes move cut $270 to $45/month (over 80%), and self-hosted-PaaS migrations routinely report 85–90% savings. The break-even question was never "is the hardware cheaper" — it is whether your team has half a day a month. Most teams paying $140+/month do.


When Not to Do This

A playbook that says "always migrate" is a sales pitch, not advice. Stay on Heroku if any of these describe you: a hobby app where Eco's $5 plus sleep is genuinely fine; a team with literally zero ops capacity and no appetite to build any (the $8/month delta on a single Basic app is not worth half a day of anyone's time); or a compliance posture where Heroku's certifications are doing documented work your auditors rely on.

For everyone else — a production app on Standard dynos, a worker, a Standard-0 database, and a bill crossing $100/month — the weekend above is the whole project. Friday inventory, Saturday fleet plus data, Sunday cutover, and a week of rollback insurance. The sustaining-engineering announcement did not raise your price. It just told you the price will never again buy anything new.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. If the weekend above sounds right but you want the git-push ergonomics without hand-operating the fleet, that is exactly the gap bex fills. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex