Your Heroku-20 app still runs. It just can't change. Since April 30, 2025, the Heroku-20 stack has been end-of-life: no security updates, and no new builds until you upgrade stacks.
Then, on February 6, 2026, Heroku announced it is transitioning to a sustaining-engineering model — no new features, no new Enterprise contracts, just maintenance. If your app is still on Heroku-20 today, you are frozen on two axes at once: your stack can't deploy, and your platform has no future to deploy toward. Don't fix both in one jump. Reproduce your frozen build locally as a portable OCI image first, then move the runtime — this post is the playbook for step one: the exact commands, the five things that break outside Heroku's base image, and what each fix looks like.
Frozen on two axes
The timeline matters because it sets your constraints. Heroku-20, the Ubuntu 20.04-based stack, was deprecated on June 24, 2024 and reached end-of-life on April 30, 2025. Heroku's changelog entry is explicit: apps on Heroku-20 keep running but receive no security updates, and no new builds — no code deployments — can be performed until the app upgrades to a supported stack. The supported targets are Heroku-24 (Ubuntu 24.04, the default, supported through April 2029) and Heroku-22 (Ubuntu 22.04, supported through April 2027), with Heroku-26 (Ubuntu 26.04) arriving as the next runway.
The second freeze landed eight months ago. On February 6, 2026, Heroku CPO Nitin Bhat published "An Update on Heroku," announcing the transition to a sustaining-engineering model with "an emphasis on maintaining quality and operational excellence rather than introducing new features" — and confirming that Enterprise Account contracts will no longer be offered to new customers. Self-serve apps keep working, security patches continue, but the roadmap is over. Salesforce's investment is going to Agentforce and its AI product line, not the PaaS it acquired in 2010.
Meanwhile the ecosystem is quietly abandoning your stack. Third-party classic buildpacks have been dropping Heroku-20 support one by one: the community apt buildpack now declares only Heroku-22 and Heroku-24, Chrome and wkhtmltopdf buildpacks cut Heroku-20 in 2026 releases, and PHP extension packs followed in March. Every month you stay frozen, fewer maintained buildpacks will even detect your app.
So the thesis: don't negotiate with either freeze. Your Heroku build is reproducible outside Heroku — Heroku's own engineers maintain the tooling for it — and a portable image decouples your escape from both clocks. Here is the playbook.
The playbook: from frozen slug to portable image
The goal of this section is one artifact: an OCI image, built on your machine, that runs your app the way Heroku-20 did. Five steps.
Step 0 — Inventory what Heroku knows about your app. Before touching pack, capture the build inputs Heroku has been holding for you. Run these against your frozen app:
heroku stack --app myapp
heroku buildpacks --app myapp
cat Procfile
heroku config --app myapp --shell | cut -d= -f1
heroku addons --app myappYou need four answers: which stack (Heroku-20, presumably), the ordered buildpack list including any third-party or apt buildpacks, the Procfile process types, and which config vars are build-time inputs versus runtime secrets. Screenshot or save this output — it becomes your parity checklist in step 2.
Step 1 — Build the image with Heroku's own CNB builder. Install the pack CLI, then run this in your app's source directory:
pack build myapp --builder heroku/builder:24That's the whole build command. heroku/builder:24 is Heroku's published Cloud Native Buildpack builder on Ubuntu 24.04, maintained in the open in the heroku/buildpacks repository with per-language tutorials. It auto-detects Ruby, Node.js, Python, Java, Go, and PHP apps the same way the platform did. Heroku's own blog post "Turn Your Code into Docker Images with Cloud Native Buildpacks" documents exactly this flow, and Heroku's Ruby buildpack maintainer has published a walkthrough building a Rails app image in five minutes with no Dockerfile.
If your app needs multiple languages — a Rails app with a Vite frontend, say — declare them in a project.toml or pass repeated --buildpack flags:
pack build myapp --builder heroku/builder:24 \
--buildpack heroku/nodejs \
--buildpack heroku/rubyOne deliberate choice here: build against :24, not a Heroku-20-equivalent builder. There is no maintained Heroku-20 CNB builder, and reproducing a dead Ubuntu is not the goal — landing on the supported base your next platform expects is. The version jump is exactly what step 3 exists to shake out.
Step 2 — Run it and check parity against production. Your Procfile's web process becomes the image's default process; run it the way any container runs:
docker run --rm -e PORT=5000 -p 5000:5000 myappNow work your step-0 checklist: hit the routes that matter, verify asset serving, confirm background job entrypoints boot (docker run myapp worker for a worker: Procfile line). You are comparing against the frozen production app, which is still running — a rare luxury in migrations. Any behavioral difference you find here is a build problem you get to fix while the old app still serves traffic, not a 2 a.m. discovery after cutover.
Step 3 — Fix what breaks (next section is the field guide). Something will break. It always does, and it breaks in predictable places — the next section catalogs the five classic-buildpack-isms that fail outside Heroku's base image, with a fix for each. Iterate on pack build until the parity check passes.
Step 4 — Push the image to a registry. Once parity holds, tag and push:
docker tag myapp registry.example.com/myorg/myapp:heroku20-parity
docker push registry.example.com/myorg/myapp:heroku20-parityThat pushed tag is the payoff of the whole exercise: your app, as a standard OCI image, buildable and runnable anywhere. The runtime migration — the subject of step two of your escape — is now just "run this image somewhere," which every platform on earth knows how to do.
What breaks outside Heroku's base image
Classic Heroku buildpacks assumed a Heroku stack image underneath: specific system packages, specific interpreter paths, specific shared libraries. CNB builders are leaner and differently shaped, and the Ubuntu 20.04 to 24.04 jump removes things your app may have been leaning on for years. Here are the five failures, in the order they tend to appear, with fixes.
| # | Breakage | Symptom | Fix |
|---|---|---|---|
| 1 | apt-buildpack system packages | Build or boot fails on a missing binary (ffmpeg, pdftk, image libraries) that the classic apt buildpack installed from Ubuntu 20.04 archives | Check whether a CNB exists for the dependency (many do, e.g. community Chrome/Chromedriver CNBs used via --buildpack); otherwise extend the run image with a Dockerfile based on the builder's run image, or vendor the binary into the repo |
| 2 | Stack-pinned binaries | A vendored or buildpack-fetched binary (wkhtmltopdf, PhantomJS-era tooling, old Chrome) segfaults or complains about missing .so files — it was linked against Ubuntu 20.04 libraries | Re-fetch the Noble (24.04) build of the binary, or replace it: most stack-pinned binaries from the Heroku-20 era have maintained 24.04 builds or maintained CNB equivalents now |
| 3 | System-Ruby/Python assumptions | App shells out to ruby, python, or gems/pip packages that existed in the stack image but aren't in the CNB runtime layer, or expects the Heroku-20 default interpreter version | Pin the runtime explicitly (Gemfile ruby directive, .python-version, package.json engines) so the CNB installs the right interpreter as a build layer instead of inheriting the stack's |
| 4 | heroku.yml / container-stack workflows | Your repo has a heroku.yml that defined the build; pack ignores it, and the app builds wrong or not at all | Note that Heroku's Fir generation doesn't support heroku.yml either — translate its setup/build/release/run phases into project.toml buildpack config plus explicit docker run commands; this also future-proofs you if you ever land on Fir |
| 5 | Build-time env and secrets | Build fails because code that ran at slug-compile time read config vars that pack doesn't have, or — worse — someone baked secrets into the build | Pass non-secret build inputs with --env (never secrets — buildpacks can persist env into image layers); move secret reads to runtime boot, matching how the app will behave on any container platform |
Two notes on this table. First, it is ordered by frequency, not severity — item 5 is rarer but the one that creates a security incident if you get it wrong, so audit it even if the build passes. Second, every fix here is platform-agnostic. Nothing in the right-hand column mentions Heroku, which is the point: you are paying down Heroku-specific coupling before choosing where to live next, not after.
Why this order works
The standard migration mistake is coupling: new build system plus new runtime plus new data layer, all cut over on the same weekend, with failures that could have come from any of the three. The image-first order inverts that. After step 4 you hold a tested artifact whose behavior no longer depends on Heroku at all — and that artifact runs on every plausible destination without modification.
Concretely, the same pushed image deploys to Heroku's own CNB stack (Heroku extended CNB support to Cedar-generation apps in September 2026 via heroku stacks:set to cnb), to Dokku or Coolify or Dokploy on a VPS you rent this afternoon, to any Kubernetes cluster via a plain Deployment, or to a self-hosted PaaS that speaks OCI images natively. The deploy half of the migration collapses to one command per destination — for example, on any box with Docker:
docker run -d --restart unless-stopped -e PORT=5000 -p 5000:5000 \
-e DATABASE_URL="$DATABASE_URL" registry.example.com/myorg/myapp:heroku20-parityAnd because production is still running while you do all of this, every parity gap is found on your schedule. The frozen app you resented in paragraph one turns out to be the perfect migration partner: an immutable reference implementation that can't drift out from under your testing because it literally cannot change.
What this doesn't solve
Honesty section. The image solves the build and the stateless runtime. Three things still need their own migrations:
Addons and data. Postgres, Redis, Elasticsearch, object storage — your image connects to them, it doesn't contain them. Heroku Postgres still runs and still takes backups, so plan the data move (pg_dump/pg_restore to a managed or self-run database, Redis migration, S3-compatible storage) as a separate cutover with its own rollback story. Data gravity is the actual long pole of most Heroku exits, not the app build.
Procfile semantics beyond web and worker. One-off dynos (heroku run), release-phase tasks, and scheduler jobs don't have container-native equivalents in a bare docker run. Map each to a cron job, a CI step, or a run-once container before cutover, and test the release-phase equivalent against a staging database first — release tasks are where "works on Heroku" most often masks "depends on Heroku."
Cedar-isms in application code. If the codebase reads Heroku-specific affordances — dyno metadata (HEROKU_DYNO_ID), the platform API, DATABASE_URL color-swap behavior — those need code changes regardless of destination. Grep for HEROKU_ and DYNO early; each hit is a small code task, and small code tasks found late become launch blockers.
None of these invalidate the image-first order. Each is easier to solve once the app's build is reproducible and portable, because every experiment — "does the release task work against the new database?" — runs against an identical artifact instead of a snowflake deploy.
The buildpack outlives the platform
There is a satisfying symmetry here. Heroku invented the buildpack in 2011, co-founded Cloud Native Buildpacks with Pivotal in 2018, and donated the concept to multi-vendor, CNCF-governed life. Eight years later, the company's own platform is frozen — and the escape hatch for its longest-stuck users is the technology it gave away. The heroku/builder:24 image you pulled in step 1 is maintained by the same engineers, in the open, on a cadence (27 CNB releases in 14 months, per Heroku's March 2026 engineering post) that the frozen platform will never match again.
So the order stands: inventory this week, pack build next, parity against the frozen production app, push the image, and only then decide where it lives. Your Heroku-20 app can't change — but it can leave.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. If your Heroku exit ends at infrastructure you control, star the repo on GitHub or deploy your first app today.
Sources
- Heroku Dev Center changelog, "Heroku-20 stack has reached end-of-life" (effective May 1, 2025) — https://devcenter.heroku.com/changelog-items/3230
- Heroku Help, "Heroku-20 End-of-Life FAQ" — https://help.heroku.com/NPN275RK/heroku-20-end-of-life-faq
- Heroku Dev Center, "Stacks" (Heroku-22/24/26 support dates) — https://devcenter.heroku.com/articles/stack
- Nitin Bhat, "An Update on Heroku" (February 6, 2026) — https://www.heroku.com/blog/an-update-on-heroku/
- Heroku Dev Center changelog, "Cloud Native Buildpacks support for Cedar-generation apps" (effective September 3, 2026) — https://devcenter.heroku.com/changelog-items/3803
- Heroku, "Turn Your Code into Docker Images with Cloud Native Buildpacks" — https://www.heroku.com/blog/docker-images-with-buildpacks/
- heroku/buildpacks on GitHub (CNB tutorials,
heroku/builder:24) — https://github.com/heroku/buildpacks - Heroku Dev Center, "Heroku Generations" (Fir CNB requirement) — https://devcenter.heroku.com/articles/generations



