Skip to main content

The Heroku Buildpack Compatibility Myth: What Render's 'Zero-Config Migration' Actually Runs On

9 min readDora NodaDora Noda
Share
On this page

On February 6, 2026, Salesforce SVP Nitin T Bhat announced that Heroku was moving to a "sustaining engineering" model: no new features, no new Enterprise contracts, and a roadmap limited to security patches and stability fixes. In software, that phase has a name — it's the one before end-of-life. Teams that had been putting off a Heroku migration suddenly had a reason to stop putting it off.

So the migration writeups came, and almost all of them landed on the same explanation for why Render, not Railway or Fly.io, kept winning the Heroku cutover: "it supports Heroku buildpacks directly." One widely-shared case study put a number on it — a Rails app moved from Heroku to Render with a render.yml Blueprint that lined up nearly 1:1 with its existing heroku.yml, zero build-pipeline rework, and a bill that dropped from $108/month to $39/month, saving $828 a year.

That's a real result. But "supports Heroku buildpacks directly" is doing more work in that sentence than the evidence backs up — and if you're the one deciding whether your app moves in an afternoon or a quarter, the gap between "buildpack support" and "Heroku buildpack compatibility" is exactly the thing worth checking before you flip DNS.

Two Specs, One Name

Heroku's buildpacks — the classic ones, still running production Cedar-generation apps today — are a specific, narrow contract: three Bash scripts.

  • bin/detect runs against your app's source and exits 0 if this buildpack applies. Heroku tries each candidate buildpack in order until one claims the app.
  • bin/compile takes two arguments, BUILD_DIR and CACHE_DIR, and does the actual work — installing a language runtime, resolving dependencies, writing config files, storing anything reusable in CACHE_DIR for the next build.
  • bin/release runs after compilation and prints YAML to stdout describing default config vars and a default Procfile — the step that lets a buildpack say "this app should run bundle exec puma" without a human writing that down.

That three-script contract is what thousands of community buildpacks on Heroku Elements and GitHub actually implement: heroku-buildpack-multi (chain several buildpacks), heroku-buildpack-apt (install arbitrary system packages), heroku-buildpack-nginx, and dozens of language forks that patch the official Ruby, Node, or Python buildpacks for some specific edge case. If your Heroku app's .buildpacks file references one of these, that script triplet is your actual build pipeline.

Cloud Native Buildpacks (CNB) — the spec underneath Render's, Railway-adjacent, and bex's own build steps — looks similar at a glance and is not the same contract. CNB buildpacks implement bin/detect and bin/build; there's no separate bin/release phase, and instead of writing files into a slug, a CNB buildpack contributes OCI image layers directly, with build-time and launch-time metadata tracked in TOML rather than a Procfile. The two specs are close enough that Heroku itself had to publish a dedicated porting guide — "Creating Cloud Native Buildpacks from Classic Buildpacks" — to walk its own team through moving its own official buildpacks from one contract to the other. If Heroku couldn't treat its own classic buildpacks as automatically CNB-compatible, no platform claiming generic "buildpack support" is running your .buildpacks file unmodified either.

Railway complicates the picture further: its Nixpacks tool isn't a buildpack implementation of either spec. It inspects your source, generates a Nix expression and a synthesized Dockerfile, and builds that — a different mechanism that happens to produce a similar zero-config experience for standard frameworks, via a completely different pipeline underneath.

Fly.io, meanwhile, actually does support real CNB buildpacks (via Paketo builders and its own pack-CLI-driven builder type) alongside Dockerfile and prebuilt-image builds — but its own docs actively discourage the option: "Don't use buildpacks if you don't have to; they're brittle, bloated, and prone to change." Fly ships the same spec Render does and steers users away from it in favor of Dockerfiles. The "Fly expects a Dockerfile" framing that shows up in migration writeups is a support-and-defaults choice, not a missing capability.

PlatformWhat actually runs your buildSame contract as Heroku classic?
Heroku (Cedar)Classic buildpacks: bin/detectbin/compile(BUILD_DIR, CACHE_DIR)bin/release (YAML)— (the original)
RenderCloud Native Buildpacks (CNB): bin/detectbin/build, OCI layers + TOMLNo — a different, later spec
Fly.ioCNB via Paketo builders (discouraged default; Dockerfile preferred)No — same spec as Render, opt-in
RailwayNixpacks: source → generated Nix expr + synthesized DockerfileNo — not a buildpack spec at all
DokkuHerokuish: runs classic bin/detect/bin/compile/bin/release unmodified inside a wrapper containerYes — literal script execution

Only one row in that table is actually running Heroku's buildpack scripts as written.

What "Buildpack Support" Gets You in Practice

Render's own community forum bears this out. Threads there show official-framework buildpacks (Ruby, Node, Python) working through Render's auto-detection without friction — the same 80% case Cloud Native Buildpacks was built to cover, where a repo's shape ("there's a package.json," "there's a Gemfile") is enough to pick a builder and go. But when a user tries to bring a non-official Heroku buildpack — one of those community forks, or a .buildpacks file listing something outside Render's own detected set — the documented answer is to email support@render.com for manual help. Render's own migration docs flag multi-buildpack Heroku apps as a case still being worked out, not a solved path.

That's the tell. Render's "buildpack support" is CNB-based framework auto-detection, applied broadly enough that it matches Heroku's classic-buildpack behavior for the common cases. It is not literal execution of the .buildpacks file your Heroku app has been running against for the last five years. For the Rails app in the $828/year case study — Puma, standard gems, no exotic build customization — that distinction never surfaces, because CNB's Ruby detection produces the same runnable app a classic buildpack would have. For a team running heroku-buildpack-apt to install a system library, or heroku-buildpack-multi to chain three buildpacks in sequence, it surfaces immediately, as a support ticket instead of a DNS change.

Dokku is the one platform on this list that closes that gap for real, and it does it by not reimplementing anything. Herokuish, the layer underneath Dokku's buildpack support, runs the exact three-script contract — bin/detect, bin/compile, bin/release — inside a container wrapper, unmodified. Point a Dokku app at the same .buildpacks file your Heroku app used, including a community buildpack Heroku itself never officially blessed, and it runs the same scripts, in the same order, expecting the same BUILD_DIR/CACHE_DIR arguments. The tradeoff is that herokuish is a much smaller, less actively maintained project than the CNB ecosystem — genuine compatibility, on infrastructure with a thinner support surface than Render's.

What Closing the Gap Would Actually Take

bex's own build pipeline sits in the same place Render's does: clone the repo, run Cloud Native Buildpacks via pack and Paketo builders, push the resulting OCI image to the registry. That's the right default — it's a maintained, standards-track spec with an active ecosystem, and it's why bex can offer the same zero-config "push a git repo, get a running app" experience Render does for the 80% of apps that fit a standard framework shape.

Matching Heroku's literal buildpack API, for the tail of apps that don't fit that shape, is a separate and specific engineering task, not a checkbox next to "we support buildpacks":

  1. Fetch and order buildpacks from a .buildpacks file or BUILDPACK_URL. A classic Heroku app declares its buildpack chain explicitly; a platform has to resolve and clone each one before build, in the declared order.
  2. Run bin/detect per candidate, honoring exit codes. Not "does this repo look like Ruby" — literally execute the buildpack's own detect script and respect its exit status, since community buildpacks encode detection logic a generic framework sniff won't replicate.
  3. Execute bin/compile with real BUILD_DIR/CACHE_DIR semantics. The cache directory has to persist and be handed back on the next build unmodified — the exact behavior heroku-buildpack-apt and friends depend on to avoid re-downloading packages every deploy.
  4. Parse bin/release's YAML into your own process model. Default Procfile entries and config vars coming out of a script's stdout, not a platform-native manifest — the step that turns compile output into "here's the command that starts your web process."
  5. Package the result into your existing image/registry pipeline so it deploys the same way a CNB-built image does downstream — the classic buildpack contract predates OCI images entirely, so this step is pure integration work with no upstream spec to lean on.

None of that is exotic engineering. It's the herokuish approach, reimplemented or vendored, sitting alongside an existing CNB build step rather than replacing it. The honest scope, though, is narrow: it only pays off for the specific subset of migrating apps that depend on non-official or custom classic buildpacks — a smaller slice than the marketing framing of "Heroku buildpack compatible" implies, but a real one. It's the exact slice that turns a documented $828/year DNS-change migration into an open support ticket if a platform hasn't built it.

Before You Read "Supports Buildpacks" as "My App Just Works"

If you're planning a Heroku cutover on the strength of a target platform's buildpack story, three questions settle whether that story applies to you:

  • Does your .buildpacks file list only Heroku's official Ruby/Node/Python/Go/PHP/Java buildpacks? If so, CNB-based auto-detection on Render, Fly, or bex will very likely reproduce the same runnable app — that's the case the $828/year migration and most "afternoon" migration stories describe.
  • Does it list a community or custom buildpackheroku-buildpack-multi, heroku-buildpack-apt, an internal fork, anything not in Heroku's officially maintained set? If so, "supports buildpacks" on a CNB-based platform means you re-derive that behavior as a Dockerfile or a custom CNB buildpack — budget for that as a real migration task, not a formality.
  • Do you need the literal script to run unmodified, right now, with no rewrite? Herokuish-based platforms like Dokku are the only ones on this list that offer that today; everyone else, bex included, is CNB-first with a Dockerfile escape hatch for what CNB's auto-detection doesn't cover.

Heroku's sustaining-engineering mode doesn't force anyone's hand overnight. But it does mean the buildpack question is worth answering with the actual API your app depends on, not the word "buildpacks" appearing on two platforms' marketing pages.


Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own via the same Cloud Native Buildpacks pipeline described above, with a Dockerfile fallback for anything that doesn't fit. 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