"No Dockerfile needed" is the entire buildpacks pitch, and it's true on day one. Push a repo, get a running container, never touch a FROM line. But a git-push PaaS doesn't build one image once — it builds thousands of images, over and over, for years, and that's where the pitch stops being the whole story. The honest comparison isn't build speed on the first push. It's which number you're measuring, because buildpacks and Dockerfiles don't just build images differently — they win on completely different clocks.
The numbers nobody benchmarks the same way
Ask "which is faster, buildpacks or a Dockerfile?" and you'll get three different, all-correct answers depending on which build you're timing.
Cold, one-time build speed favors a plain Dockerfile. A widely cited Spring Boot containerization benchmark — comparing a shell-plus-Dockerfile build, Google's Jib, and Cloud Native Buildpacks on the same app — ranks single-image build speed as Jib > Dockerfile+shell >> Buildpacks, with buildpacks consistently the slowest of the three for a from-scratch build. That's one Java-specific study, not a universal law, but it lines up with what buildpacks maintainers themselves acknowledge: the detect phase alone — walking every registered buildpack to figure out what your app even is — adds overhead a Dockerfile never pays, because a Dockerfile already tells the builder exactly what to do.
Repeat-build speed is where buildpacks stop looking slow and start looking like a different category of tool. Heroku's own engineering blog puts a number on it: pack rebase "will update your image in less than a second without rebuilding. This saves an enormous amount of time compared to rebuilding from a Dockerfile on every one of your apps — a process that can take hours." That's not marketing rounding — it's the structural difference between an image built as swappable, content-addressed layers (OS layer, runtime layer, app layer, each independently reusable) and an image built as a linear script where changing line one invalidates every line after it. A cold build is a single event. A base-image CVE patch is an event that repeats across every app on the platform, indefinitely — which is the clock a PaaS actually lives on.
Image size is mostly a base-image decision wearing a buildpacks-vs-Dockerfile costume. Google Cloud's own comparison of building the same Spring Boot app three ways found:
| Build method | Image size | Layers |
|---|---|---|
| Cloud Native Buildpacks | 319 MB | 11 |
| Dockerfile (hand-written) | 209 MB | multiple, manually ordered |
| Jib | 127 MB | dependencies/resources/app split cleanly |
| Dockerfile + GraalVM native image | 11 MB | minimal, no JVM |
The buildpacks image is the biggest of the four — but the 11 MB GraalVM number isn't a "Dockerfiles win" data point, it's a "swapping the entire runtime model wins" data point, something a buildpack builder image could adopt too if one shipped a GraalVM-based stack. The same pattern shows up one layer down, in plain base-image choice: node:24 is roughly 1.1 GB, node:24-slim around 200 MB, node:24-alpine around 130 MB — a 9x spread from picking a different tag, before a single line of app-specific Dockerfile or buildpack logic runs. Most of what people call "the buildpacks image size problem" is actually "the builder's base image is heavier than the Dockerfile I would have hand-picked," which is a real cost, but a different one than the tool itself being wasteful.
So: cold build favors a Dockerfile, rebuild-and-patch favors buildpacks by orders of magnitude, and image size mostly comes down to whose base image you're stuck with. None of the three answers make the other two wrong — they're just not the same question.
That split matters more on a multi-tenant platform than on a single developer's laptop. A developer building one image cares about the first number — how long until docker build finishes so they can test the thing. A platform running thousands of tenant apps cares about the second and third — how long a fleet-wide base-image patch takes to land, and how much disk and registry-egress a tenant's image costs every month it stays deployed. Those are exactly the two numbers a Dockerfile's cold-build advantage doesn't touch, which is why "buildpacks benchmarked slower" and "buildpacks is the right default for a git-push PaaS" aren't in tension.
What each approach leaks back to the developer
"No Dockerfile" doesn't mean "no decisions." It means the decisions move somewhere less visible.
A Dockerfile makes you own, explicitly:
- Layer ordering, so
COPY package.jsonhappens beforeCOPY .and a code change doesn't bust the dependency-install cache - Base image selection, and everything that comes with it — which CVEs land in your image, when you patch them, whether
alpinemusl breaks a native dependencyslim's glibc wouldn't - Multi-stage build structure, if you want a small final image instead of shipping your build toolchain to production
- Non-root user setup and signal handling, so
SIGTERMactually reaches your process instead of dying with the container - The
PORT/health-check contract your process actually listens on, since nothing infers it for you — get it wrong and the platform's own readiness probe just times out with no obvious error
Buildpacks make you trust, implicitly:
- The builder image's own base and update cadence — you don't choose the OS layer, the builder's maintainer does, on their schedule
- Per-language "magic" you didn't ask for and can't always see: Paketo's JVM buildpack calculates container memory limits for you, which is right until it isn't and you're debugging an OOM-killed container with no
Dockerfileto grep - Quirks of reproducible builds — buildpacks images are famously "42 years old" by file timestamp, because reproducibility pins timestamps to a fixed epoch, which is invisible until some tool in your pipeline chokes on it
- What happens when a buildpack update ships something you didn't ask for. One platform vendor's own build-vs-Docker comparison lists the real support tickets this generates: buildpack version bumps breaking container launches without warning, no path to install a custom PHP extension a buildpack didn't anticipate, build timeouts on larger apps, and no ARM support — all cases where "buildpacks just work" quietly became "buildpacks just work until your app is the one they didn't plan for."
Neither list is shorter than the other. A Dockerfile leaks detail up front, in text you write and can grep. Buildpacks leak detail later, at the moment the abstraction doesn't hold, which is a worse time to learn about it.
The update problem, briefly
The rebase number above — under a second versus hours — is really about one thing: swapping a base-OS layer without touching the app layer above it. That's a deep enough topic (rebase mechanics, which base-image changes are rebase-safe, when a full rebuild is unavoidable anyway) that it deserves its own treatment rather than a rushed paragraph here. The short version for this comparison: it's the single biggest structural argument for buildpacks on a platform running many tenants' apps, and it's also the argument least visible to a developer evaluating the tools by pushing one repo once.
Choosing for a self-hosted git-push PaaS
None of this resolves to "buildpacks win" or "Dockerfiles win" — it resolves to a decision a platform's build step has to make per incoming repo, not once for the whole product:
| Situation | Right default |
|---|---|
| Repo has no Dockerfile, common language/framework | Buildpacks — zero config, and the platform inherits base-image patching for free |
| Repo already ships a Dockerfile | Respect it — the developer already made the control tradeoff explicitly |
| App needs a custom system dependency, extension, or unusual base image a buildpack can't express | Dockerfile fallback, no exceptions |
| Platform runs on ARM (a real gap in some buildpack builder images today) | Verify builder ARM support before defaulting to buildpacks, or fall back to Dockerfile |
| Fleet needs to patch a base-image CVE across hundreds of tenant apps at once | Buildpacks' rebase path, not a mass rebuild |
That's the actual shape of the decision a Render-compatible build step has to encode: buildpacks as the zero-config default because most repos are the common case, a Dockerfile as a first-class fallback because "full control" is sometimes the correct answer and not a failure to configure things properly, and the base-OS patching story treated as an operational concern the platform owns — not something pushed back onto every tenant to solve for themselves on their own schedule.
In practice that decision runs once, silently, at the top of every deploy: the build step checks the repo root for a Dockerfile first. Found one — build it exactly as written, full stop, no buildpack detection even attempted. Not found — run buildpack detect against the repo, pick the matching language/framework buildpack, and build from there. The tenant never has to declare which path they want; the repo's own contents already answered the question. The only place this needs a human decision at all is the exception list above — ARM, an unsupported system dependency, a base image a buildpack genuinely can't express — and even then, the fix is "add a Dockerfile," not "file a platform support ticket."
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with both buildpack auto-detection and Dockerfile support built into the same deploy path. Star the repo on GitHub or deploy your first app today.
Sources
- Dockerfiles vs. Cloud-native Buildpacks — Michael Vittrup Larsen
- Compare image size and building time between Dockerfile, Buildpack, and Jib for multi-module Spring Boot apps
- Buildpacks, Jib, or Dockerfile: Which method should you choose? — Google Cloud Blog
- Turn Your Code into Docker Images with Cloud Native Buildpacks — Heroku
- Why You Should Use Docker Over Buildpacks — Qovery Blog
- Choosing the best Node.js Docker image — Snyk
- Railpack vs. Nixpacks: Which Containerization Tool Wins in 2026?



