Push a repo, get a running service, never touch a Dockerfile — that's the buildpack pitch every git-push PaaS repeats, from Heroku's original not-really-a-buildpack to Cloud Native Buildpacks' detect-then-build lifecycle. It's a good pitch, and it's true for most repos. It's also the reason the failure case is so jarring when it hits: the build doesn't produce a broken image you can debug. It produces no image at all, exit code 20, "no buildpack group passed detection" — and no file to open and fix, because there was never a build script to begin with.
How often does that actually happen, and what should a platform do about it? Not "always add a Dockerfile fallback" — that's the easy, incomplete answer nearly every buildpacks-vs-Dockerfile comparison lands on. The real answer is a fraction worth putting a number on, and a fix worth being more specific than "here, write a Dockerfile."
Roughly 1 in 5 repos, and here's the arithmetic
Start with the one hard number a buildpack vendor has actually published about its own failure rate. Railway ran Nixpacks, its zero-config builder, as the default build path for its ~1 million users for three years. In its March 2026 postmortem announcing Railpack as the replacement, Railway put it plainly: Nixpacks "works great for 80% of users" — which leaves roughly 200,000 users hitting friction (unpredictable image sizes from Nix's single /nix/store layer, version pinning tied to a single commit hash, cache invalidation on every deploy) often enough that it justified a from-scratch rewrite, in Go, on BuildKit.
That 20% isn't a clean measurement of "needed a Dockerfile" — it's Nixpacks-specific implementation pain, and Railway fixed most of it without asking anyone to abandon zero-config builds. But it's the best public evidence that even a mature, widely-used auto-detect builder leaves a fifth of real usage unsatisfied by default. Treat it as an upper-bound sanity check, not the number itself.
For the number itself, build it from what actually triggers a supported-list failure, which is a narrower and more specific problem than "the builder has rough edges." Two buckets:
Bucket 1 — the language isn't on the list at all. Paketo, the reference Cloud Native Buildpacks implementation, ships official, zero-config builders for Java, Node.js, Python, Go, and .NET. PHP and Ruby get "how-to" guides instead — meaning a tenant has to assemble their own buildpack, not just push code. Render's own docs confirm the same split from the platform-operator side: reach for Docker if your app "uses a language that Render doesn't support natively, such as PHP or JVM-based languages like Kotlin or Scala."
PHP alone accounts for 19.1% of professional developers according to Stack Overflow's 2025 Developer Survey — but a large share of that is WordPress and shared-hosting work that was never going to land on a git-push PaaS as a fresh service in the first place. Discount hard for that, and call PHP-shaped, git-push-relevant traffic something like half its raw usage share — roughly 9-10% of a representative tenant population.
Bucket 2 — the language is supported, but the app needs one thing the base image doesn't have. This is the harder-to-source bucket, but it's not hypothetical: it's common enough that Heroku maintains a dedicated, first-party buildpack for exactly this case. The classic heroku-buildpack-apt let a tenant drop an Aptfile listing system packages (ffmpeg, imagemagick, a native compression library) and get them installed alongside an otherwise-standard build.
When Heroku moved its Fir generation onto Cloud Native Buildpacks natively, the classic apt buildpack got a direct, purpose-built successor — the .deb Packages CNB — because "your app requires a binary that isn't included in the base image by default, such as ffmpeg" turned out to be common enough to need a permanent, non-optional product. Call this bucket a conservative 5-8% of repos: fewer than the language-gap bucket, but nonzero and persistent across every supported language.
Add the two buckets and you land at roughly 14-18% — call it about 1 in 5, or about 1 in 6 if you're being conservative — landing close enough to Railway's independently-measured 20% that the two numbers corroborate each other despite measuring different things. That's the estimate this piece is built on: not a precise measurement, a bounded Fermi estimate with its assumptions shown, so you can adjust the inputs and get your own number instead of taking "1 in 5" on faith.
What "doesn't match the supported list" looks like mechanically
The reason that failure reads as opaque instead of debuggable is worth being specific about, because it's the actual design problem, not just an inconvenience.
A Cloud Native Buildpacks builder ships an order.toml — a prioritized list of buildpack groups, each group a set of buildpacks marked required or optional. The detect phase walks that list in order. For each group, every required buildpack's bin/detect script has to exit zero, and the group's combined requirements and provisions have to resolve into a valid build plan — every dependency a buildpack provides has to be required by something, and every dependency a buildpack requires has to be provided by something earlier in the group. The first group that satisfies both conditions wins and moves to the build phase.
If nothing satisfies both conditions, the lifecycle returns exit code 20 ("all buildpack groups failed to detect, no errors") or 21 (same, but at least one buildpack script actually errored) and stops. There's no partial build, no generated file to inspect, because nothing ran.
Compare that to a Dockerfile failure — a RUN pip install step that can't find a package prints exactly which line failed, and you edit that line. A rejected buildpack detect phase tells you the group didn't match, not which specific expectation your repo violated, unless the platform in front of it does the work of translating "group 3's required buildpack didn't detect" into "we didn't find a composer.json, Gemfile, or pom.xml — is this PHP, Ruby, or Maven, and did you mean to add a Dockerfile?"
That gap — the difference between a lifecycle exit code and an actionable message — is entirely a platform-layer problem. CNB doesn't owe you a friendly error. The PaaS wrapping it does.
The false binary: three tiers, not two
Nearly every buildpacks-vs-Dockerfile piece frames the decision as binary — zero-config buildpack, or hand-roll a Dockerfile — and skips the tier in between that the ecosystem's own tooling already proves is worth building separately. A buildpack-first, git-push platform should ship three:
Tier 0 — pure convention. Push a repo, the detector matches a group, you get an image. No file to author, no decision to make. This is the 80-85% majority case and the entire reason the buildpack pitch is worth making in the first place.
Tier 1 — a structured package extension. For the repo that's in a fully supported language but needs one extra system binary or library, the fix shouldn't be "write a Dockerfile" — it should be a narrow, declarative escape hatch scoped to exactly that gap. Heroku's .deb Packages CNB is the reference shape: name the .deb packages you need, the buildpack installs them without requiring root and without breaking the base image's rebasing story. Cloud Native Buildpacks generalizes the same idea at the platform-operator level with image extensions — components that generate a restricted-syntax build.Dockerfile or run.Dockerfile used specifically because ordinary buildpacks run as non-root and can't touch OS-level packages themselves. Both exist for the identical reason: most "I need a Dockerfile" requests are actually "I need one more package," a narrower ask than a full custom build script.
Tier 2 — the real Dockerfile. For everything else — an unsupported language, a build with genuinely unusual multi-stage requirements, a team that already maintains a Dockerfile and doesn't want a second build definition — the platform should accept it directly, on the same git-push path, with the same deploy API. No separate product, no second-class citizen.
The point isn't that Tier 2 shouldn't exist. It's that a platform offering only Tier 0 and Tier 2 forces every Bucket-2 repo — the ones that just needed ffmpeg — to give up everything Tier 0 buys them, over one missing package.
What jumping straight to Tier 2 actually costs
The cost of skipping Tier 1 isn't abstract, and it isn't paid once. It's paid on every rebuild.
A buildpack-built image separates the application layer from the OS/runtime base layer specifically so a platform can rebase — swap in a patched base image without rerunning the whole build — when a CVE lands in a system library. One base-image bump propagates the fix across every tenant image built from it, automatically, without touching application code. A hand-rolled Dockerfile has no equivalent lever: the base image is whatever FROM line the tenant wrote, and a CVE fix means finding, editing, and re-testing every affected Dockerfile individually. That's the exact tradeoff Heroku's own Fir documentation cites as the reason the .deb Packages CNB exists instead of just telling every tenant with a missing binary to "use a Dockerfile" — installing packages "in a CNB-friendly manner that does not require root permissions or modifications to system files that could invalidate how CNB rebasing functionality works" is a sentence written specifically to protect the patch-day story a full Dockerfile bailout throws away.
Multiply that by a fleet instead of one app, and the gap compounds. A platform running thousands of tenant services wants CVE day to mean one base-image rebuild, not thousands of individually-triggered ones — and every repo pushed into Tier 2 only because Tier 1 didn't exist is a repo that opted out of that story for a package it could have named instead of building around.
What a git-push platform should actually ship
Two concrete changes fall out of this, and neither is "add Dockerfile support" — most platforms, bex included, already have that.
First, detect-phase failure needs to surface why, not just that. When a group's detect scripts fail, the platform sitting in front of the CNB lifecycle already knows which buildpacks were tried and what each one looked for — a package.json, a go.mod, a requirements.txt. Translating that into "we checked for Node, Python, Go, Java, and .NET project files and didn't find any — is this PHP, Ruby, or another language, or did you mean to add a Dockerfile?" turns exit code 20 from a dead end into a decision the tenant can act on immediately, without opening a support ticket to find out what "detection failed" even means.
Second, Tier 1 needs to exist as a named, documented feature before Tier 2 is the only advertised alternative to zero-config — a declared list of system packages a tenant can request without leaving the buildpack path, modeled on the Aptfile/.deb Packages pattern the ecosystem has already validated twice, once on Heroku's classic stack and again on Cloud Native Buildpacks natively. It's a small surface to build and it closes the single largest bucket in the estimate above without asking anyone to give up rebasing, patch-day fixes, or the zero-config build they picked the platform for in the first place.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a buildpack-first deploy path and a Dockerfile fallback for the repos that genuinely need one. Star the repo on GitHub or deploy your first app today.



