Buildpacks vs Dockerfiles vs framework detection: how a platform decides how to build your repo
Before a deploy platform can do anything useful with your git repository it has to answer a question that sounds trivial and is not: what is this, and how do I build it? Every platform you have used answers it differently, and the answer determines almost everything about how the product feels — whether the first deploy just works, whether the second one is reproducible, and how bad the failure is when the guess is wrong.
I'm Ajay; I build PandaStack, which does git-driven app hosting on Firecracker microVMs. I have now implemented this three times, badly twice. What follows is the honest comparison of the three approaches, and then the ladder we actually ship, which is a compromise between all of them.
Approach 1: make the user write a Dockerfile
The simplest thing a platform can do is refuse to guess. You write a Dockerfile, the platform builds it, the image is the contract. This is what most Kubernetes-shaped platforms do and it has real virtues that people who dislike Dockerfiles tend to undersell.
It is explicit. What runs in production is what you described, and when it breaks you can reproduce it on your laptop with one command. It is portable — the same file works on four different platforms. And it has no magic to debug, which matters more than it sounds: a build that fails because of something you wrote is a much better afternoon than a build that fails because of something the platform inferred.
The cost is the first five minutes. Somebody who wants to put a Next.js app online now has to learn multi-stage builds, layer caching, why their image is 1.2 GB, and why `npm ci` reinstalls everything on every push. For a large fraction of deploys this is ceremony around a repo whose build is genuinely `npm install && npm run build && npm start`. And Dockerfiles rot: the one in your repo pins Node 18 long after your CI moved to 22.
Approach 2: buildpacks
Buildpacks were Heroku's answer and are now a Cloud Native Buildpacks specification. The model is a set of detect-and-build modules: each buildpack is asked "do you apply to this repo?", the ones that say yes contribute layers, and the output is an OCI image built without anybody writing a Dockerfile.
What buildpacks get genuinely right is rebase. Because the runtime base image is a separate layer from your application layers, a security patch to the base can be swapped underneath thousands of images without rebuilding any of them. If you operate a platform with a compliance obligation, that property is worth a great deal and nothing else in this article has it.
The costs are real too. Buildpack builds are slow, because the detect phase runs a lot of modules and the export phase does real work. The abstraction is opaque when it misfires — knowing which buildpack decided your Python app was a Node app, and why, involves reading buildpack source. And you need a builder image: a curated, versioned collection of buildpacks that somebody has to maintain and keep current. Nixpacks and similar tools are lighter-weight takes on the same idea, trading the OCI specification and the rebase property for speed and a simpler mental model.
Approach 3: framework detection
The third approach skips images entirely. Read the repo, recognise the framework, and run the commands that framework needs. This is roughly what Vercel does for Next.js and what every "zero-config" deploy button is doing under the hood.
When it hits, it is the best experience of the three by a distance. You connect a repo and it is online, no file added, no configuration, no image built. The detection is also cheap — you are reading a package.json, not running a build.
When it misses, it is the worst of the three. "No start command found" is a useless error. Worse is a wrong-but-plausible guess: your Vite app gets built and then served by the dev server, or your Django project starts under the development runner, and it works well enough in staging to reach production before anyone notices. Pure detection also has a hard ceiling — nobody is going to write and maintain detectors for every framework in every language, so there is always a long tail of repos it simply cannot deploy.
What I ended up shipping: a ladder, not a choice
The insight that took me two rewrites is that these are not competing answers to one question. They are answers of decreasing confidence, and you should try them in order. Our deploy pipeline runs five rungs, and each one only fills in what the rungs above it left blank.
- An explicit framework pinned on the app. If you told us what this is, we believe you and stop guessing.
- A pandastack.json in the repo. The repo declares its own build and start plan. This is the Vercel-style convention and it exists because some repos need to be built one way and served another — a static export that must be served from a directory, not started with the framework's own server.
- Auto-detection from repo signals. We read package.json, lockfiles and language markers once, and classify: next, vite, cra, webpack, node, static, python, go, or generic. The same pass picks the package manager, so a pinned framework still gets npm, pnpm, yarn or bun correctly.
- Dockerfile-as-plan. If detection came back generic and the repo ships a Dockerfile, we extract install, build and start hints from it. We do not run docker build.
- Railpack. If we are still planless, a build-plan analyser baked into the base image inspects the repo and returns a plan plus the language versions it needs, which we pin through mise.
Rung four is the one people find surprising, so it is worth being precise about it. We read the Dockerfile as a source of information about how the author intends the app to be built, and then we run those steps directly in the sandbox. We do not build a container image.
Rung five is what turned the long tail from a support ticket into a deploy. Go, Rust, Ruby, PHP, Java and Elixir repos used to fail at "no start command". Now a build-plan analyser looks at them and returns something runnable, and the tool versions it resolves get pinned through the same version manager that handles a .nvmrc or a .python-version. That last part matters: the plan and the runtime have to agree, or you get a build that resolves Go 1.22 and a runtime that ships whatever was baked into the image.
The rule that matters more than the ladder: say what you decided
Automatic behaviour is only tolerable if it is legible. Every rung writes a line into the build log, so the first thing you see in a deploy is what we concluded and why.
==> detected framework: vite (package manager: pnpm)
==> runtime node@22.11.0 (from .nvmrc)
==> install: pnpm install --frozen-lockfile
==> build: pnpm run build
==> serving static output from dist/
==> health check on :3000 ... ok
==> flipped liveAnd when we get to the bottom of the ladder without a plan, the failure has to name the fix rather than the symptom. "No start command found" is a bug report about our error message. What it should say is which signals we saw, what we concluded from them, and the exact field to set.
{
"type": "python",
"install": "pip install -r requirements.txt",
"build": "python manage.py collectstatic --noinput",
"start": "gunicorn myproject.wsgi:application --bind 0.0.0.0:$PORT",
"port": 8000
}That file is rung two. It is the escape hatch, and a good platform makes reaching for it feel like a normal thing to do rather than an admission that the magic failed.
Which one should you want
- You have a fleet of services, a platform team and a compliance requirement to patch base images: buildpacks. The rebase property is genuinely hard to replicate and it is worth the build latency.
- You have one complicated service with unusual system dependencies and you already know Docker: write the Dockerfile. Explicitness beats inference and you will not be fighting anybody's heuristics.
- You are deploying an app of a shape that thousands of people deploy — a JavaScript framework, a Python web app, a Go binary — and you want it online today: detection, with a declared escape hatch for when you outgrow it.
- You do not know yet: pick the platform whose detection tells you what it decided. That property survives being wrong; opacity does not.
One last thing about our specific case, because it changes the tradeoff. Because we deploy into a microVM rather than a container, there is no image to build, push or pull in the happy path — the build runs in the same isolated environment the app will run in. That removes a whole category of "works in the build, fails at runtime" bugs, and it is the reason the detection-first ladder is a better fit for us than it would be for a platform whose deploy unit is an OCI image.
Frequently asked questions
What is the actual difference between buildpacks and Nixpacks?
Cloud Native Buildpacks are a specification with a detect-and-build lifecycle and a curated builder image, and their standout feature is rebase: because the runtime base is a separate layer, you can patch it under existing images without rebuilding them. Nixpacks is a lighter tool that inspects a repo and generates a build, backed by the Nix package set for dependencies. Nixpacks is generally faster and simpler to reason about; buildpacks give you the layer model and the rebase property. If you do not need rebase, the specification is mostly cost.
If a platform can read my Dockerfile, why not just build it?
It depends on the deploy unit. If the platform runs containers, building the image is the obvious move. If it runs microVMs, building an image adds a container build, a registry round trip and a container runtime inside the VM in order to get isolation the VM already provides. In that case the Dockerfile is more useful as a description of the intended build than as a thing to execute, so the install, build and start steps are extracted and run directly.
Why did my app deploy but serve the wrong thing?
Almost always a framework misdetection that was plausible rather than obviously wrong — a static-export build served by a framework's own server, or a production build started with a development runner. This is the characteristic failure of pure detection and the reason a declared escape hatch matters. Pin the framework explicitly, or commit a repo-level manifest with the install, build and start commands you actually want, and the detection step is skipped entirely.
How do runtime versions get chosen if there is no Dockerfile?
From the idiomatic files your language already uses: .nvmrc, .python-version, .tool-versions or a mise.toml. A version manager reads them at deploy time and installs the requested toolchain, so the Node or Python version in production is the one your repo already declares to every other tool. When a build-plan analyser resolves a version instead, that pin is fed into the same version manager, so the plan and the runtime cannot disagree.
Is zero-config deployment reliable enough for production?
For common application shapes, yes, provided two conditions hold: the platform tells you in the build log what it detected, and there is a first-class way to override it that you are expected to use. Zero-config as a default with a declared override is fine. Zero-config as the only path is not, because the long tail of repos is enormous and no set of heuristics covers it.
Keep reading
- PandaStack app hosting — The deploy pipeline described here, including the detection ladder and blue-green flips.
- Deploy a Go app without a Dockerfile — The long-tail case that motivated the last rung of the ladder.
- How to debug a failed deployment — What to read in the build log when a rung guesses wrong.
- Build-time vs runtime environment variables — The other thing that silently changes what your build produces.
49ms p50 cold start. Fork, snapshot, and scale to zero.