Your build fails at 2 a.m. because a Node app pulled in sharp for image resizing, and sharp's native binding needs libvips compiled against system headers the zero-config builder never installed. The buildpack's detect phase didn't error out with "unsupported dependency" — it passed detection cleanly, because package.json looked like every other Node app it had ever seen, and then failed three minutes into the build with a linker error that means nothing to whoever's on call. That failure mode is the actual subject of this post, not a hypothetical.
It's also the exception, not the rule. A widely cited 2026 tooling comparison — the Launchverse engineering guide's breakdown of Dockerfile-vs-buildpack-vs-Nixpacks decisions — puts it plainly: a build that's "install dependencies, build, start," with no exotic steps, describes roughly 80% of new web apps shipped this year. For that 80%, Cloud Native Buildpacks, Nixpacks, and Railway's newer Railpack all auto-recognize Node, Python, Ruby, Go, PHP, and Java, and produce a runnable image with zero configuration — the same convention-over-configuration bet Heroku made in 2011, now table stakes across Railway, Render, Fly.io's community buildpacks, and a git-push PaaS like bex. Nobody's writing a Dockerfile for a stock Express app anymore, and they shouldn't have to.
The same guide draws the line for the other 20% just as plainly: the moment you need specific OS dependencies, FFI bindings, or a custom multi-stage build pipeline, you write a Dockerfile. That's not a vague warning — it's a specific, checkable list, and it's the one this post works through: what's actually in that 20%, why a zero-config detector structurally can't infer it, and what happens the moment your app crosses into it on a platform that didn't plan for the crossing.
What Detection Is Actually Doing
Zero-config builders aren't reading your code. They're reading its shape. The Cloud Native Buildpacks spec — the CNCF-governed standard behind Paketo and, transitively, a chunk of what Nixpacks and Railpack independently reinvented — makes this explicit in its detect phase. A builder ships an order.toml listing groups of candidate buildpacks in priority order. For each group, every required buildpack in it runs a detect script against your source tree; the lifecycle picks the first group where every required buildpack's script exits zero, writes it to group.toml, and that's your build plan. A Node buildpack's detect script is, in essence, "does package.json exist." A Go buildpack's is "does go.mod exist." It's pattern matching on the presence of a manifest file, not static analysis of what your code does at runtime.
That's precisely why it works so well for 80% of apps and precisely why it can't work for the rest. "Does package.json exist" cannot answer "does this app's npm install step need libvips on the build image," because that fact isn't in package.json — it's a runtime property of a specific npm package's native bindings, several dependency layers deep. No amount of smarter pattern-matching closes that gap; it's not a bug in any one buildpack, it's what "detect from file shape, not from execution" structurally cannot see.
The Four Things Detection Can't Infer
Line the real failure categories up against that mechanism and each one traces back to something a manifest-shape check has no way to know:
1. Native compiled dependencies needing system packages. sharp and libvips, node-canvas and Cairo/Pango, any ffmpeg-shelling video pipeline, psycopg2 compiled against libpq-dev instead of using the pure-Python psycopg2-binary wheel — all pass detection cleanly (the manifest looks like any other), then fail or silently fall back to a slow shim at build time because an apt-get install step never ran. The buildpack detect script has no field in package.json or requirements.txt to check for "this dependency also needs a shared library the OS doesn't ship by default."
2. Unsupported or niche runtimes. Heroku's own buildpack documentation names this directly: an app in a language without an official buildpack — historically Rust, Elixir, Zig, anything outside the maintained set — fails detection outright, and the fix Heroku documents is to stop relying on auto-detection and specify a buildpack explicitly. This is the cleanest failure of the four, because it's loud: nothing ambiguous happens, the build simply won't start.
3. Multi-stage build separation. This is the one zero-config tools get structurally wrong even when detection succeeds, and it's the most expensive because nobody notices until the bill or the cold-start latency does. A hand-written Dockerfile compiles in one stage and copies only the runtime artifact into a slim final image. Nixpacks, by contrast, has historically shipped everything through the Nix store as a single layer — no discard step for build-only tooling.
The size gap shows up in production: Nixpacks images commonly land at 800MB or larger, with a plain Next.js app measured around 2.5GB and a compiled Rust binary around 3GB, next to a hand-tuned Dockerfile doing the equivalent build in a fraction of that. Railway's own Railpack rewrite exists specifically to close this gap — its BuildKit-based approach reports 38% smaller base Node images and 77% smaller base Python images than Nixpacks produced for the same apps, by finally giving the builder a real discard step between "what compiled this" and "what runs this." The category didn't go away with a better tool; it got measurably smaller, which is a different claim than "solved."
4. Non-standard entrypoints and monorepo layouts. A detect script generally expects the manifest it's looking for at a conventional path. A monorepo with three services under apps/*, a build step that needs to run from a workspace root before descending into a package, or an entrypoint that isn't npm start — none of these are exotic engineering, but none of them are things a "does this file exist at this path" check reliably handles without a config file's worth of hints, at which point you've partially reinvented the Dockerfile you were trying to avoid.
None of these four are edge cases in the "one weird app" sense — every backend team running image processing, video, or a monorepo has hit at least one. They're just the specific, enumerable shape of the 20% the Launchverse framing names in the abstract: OS dependencies, FFI, and custom build pipelines, spelled out.
Two Ways a Platform Can Respond — And They're Not Equivalent
What happens next is where platforms actually diverge, and it's a design choice, not an inevitability. Dokku's builder selection order makes the good version concrete: it tries the herokuish/pack (buildpack) detection paths first, and only falls back to building from a tenant's own Dockerfile if one exists in the repo and buildpack detection didn't pass. Detection failure isn't a dead end — it's a routing decision to a build path the platform already fully supports, made before the tenant ever sees an error.
Compare that to what a platform without that fallback wired in does when detection just fails: nothing routes anywhere. Heroku's documented behavior for an unsupported language is exactly this — the build stops with an error, and the tenant's own next step is to go figure out how to specify a buildpack manually, mid-incident, with no default path already built for them.
It's not that Heroku's detection is worse engineering than Dokku's; it's that "detect, then stop" and "detect, then fall back to Dockerfile if one's present" are two different product decisions sitting on the identical detect-phase mechanics described above. The second one costs a platform nothing at build time it wasn't already capable of doing, and saves the tenant a debugging session at exactly the moment they're least equipped for one.
Railway's own advice to its users, once they've outgrown Nixpacks-style zero-config, is the same conclusion stated as a recommendation rather than a fallback rule: for production workloads that care about image size, build speed, or genuine build-step control, invest the roughly 30 minutes to write a proper Dockerfile rather than fight the detector. That's not a knock on zero-config — it's an admission, from the team that builds the zero-config tool, of exactly where its ceiling sits.
What a Self-Hosted Build Pipeline Should Actually Do
For bex — or any git-push PaaS taking the same buildpack-first bet as Railway, Render, and Heroku before them — the design conclusion falls directly out of the four categories above, not from a general philosophy about flexibility. Detection should stay the default path, because it correctly serves the 80% of apps with nothing exotic to declare. But the moment a required buildpack's detect script fails, or a build errors on a missing system library mid-install, the platform's own responsibility is Dokku's move, not Heroku's: check for a tenant-supplied Dockerfile and hand the build off to it cleanly, with the specific reason detection didn't pass surfaced in the build log — not a generic failure, and not a forced buildpack retry that just fails the same way again.
That's a bounded, concrete engineering commitment: a Dockerfile-path check wired into the build router, and a build-log message that names which of the four categories tripped detection, so a tenant debugging a 2 a.m. failure sees "no system library for this native dependency" instead of a raw linker error three layers removed from the actual cause. It's the difference between a platform that treats the 20% as a debugging problem for the tenant to solve blind, and one that treats it as a known, enumerable fork in its own build pipeline — because by the four categories above, it is one.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with zero-config detection for the 80% and a clean Dockerfile fallback for the rest. Star the repo on GitHub or deploy your first app today.



