Multi-Stage Dockerfile vs. Separate Dockerfiles: A Decision Framework
Every Docker tutorial shows you the same thing: a builder stage compiles your app, a final stage copies the binary with COPY --from=builder, and you end up with a smaller image. That part is easy. What almost nobody talks about is the actual engineering decision behind it: when does cramming everything into one multi-stage Dockerfile start hurting you, and when should you split your build into separate files instead?
This isn't a style preference. It affects your CI cache hit rate, how fast your pipeline runs, and whether five engineers can touch the same repo without stepping on each other's builds.
What Multi-Stage Builds Actually Buy You
A multi-stage Dockerfile lets you use multiple FROM instructions in one file, where each one starts a new build stage. You compile, test, or bundle assets in early stages, then copy only the artifacts you need into a lean final stage. The compiler, dev dependencies, and source code never make it into the shipped image.
That's the whole pitch, and it's genuinely useful for cutting image size and attack surface. But the docs stop there, as if the only decision left is where to put your COPY --from line. In practice, once you have more than one service or a team bigger than two people, the harder question is structural: one file or many.
Axis 1: CI/CD Cache Behavior
Docker's build cache works layer by layer, and each stage in a multi-stage build has its own cache lineage. If you change one line in your dependency install step, every layer after it invalidates, even in stages that have nothing to do with that change.
Here's where this bites people. Say your Dockerfile has a deps stage, a build stage, and a test stage all chained together before the final image. A one-line change to a test file can invalidate cache in stages that logically shouldn't care about it, because BuildKit still evaluates the file in order unless you've been careful with instruction ordering.
Separate Dockerfiles sidestep this. If your test suite lives in its own Dockerfile with its own cache scope, a source change doesn't force your production build to redo dependency resolution. You lose the convenience of one file, but you gain predictable, isolated cache invalidation per pipeline stage.
This matters even more in CI/CD systems where cache is pulled from a remote registry rather than a local Docker daemon. If you're structuring a GitHub Actions pipeline with docker buildx build --cache-from and --cache-to, splitting build concerns across files gives you finer control over exactly which cache layers get pushed and pulled, instead of one monolithic cache blob that's either fully valid or fully stale.
Axis 2: Shared Base Stages Across Services
This is the scenario the official docs never mention, and it's the one that actually changes team behavior. If you run five microservices that all share the same runtime, same OS packages, and same security patches, do you really want five nearly identical multi-stage Dockerfiles?
The common fix people reach for is copy-pasting a FROM node:20-slim AS base block into every service's Dockerfile. That works until a CVE forces a base image bump, and now you're editing five files and hoping you didn't miss one.
A cleaner pattern is a dedicated base Dockerfile, built and pushed as its own image, that every service's Dockerfile then extends with a normal FROM your-org/base:1.4. This isn't a multi-stage build anymore in the single-file sense, it's a shared image dependency. You get:
- One place to patch OS packages and runtime versions
- Faster individual service builds, since the base layer is already built and cached in the registry
- A clear versioning boundary between "base image changed" and "application code changed"
The tradeoff is coordination overhead. Someone owns the base image, and every service now has an external dependency that needs its own release cadence. If your team doesn't already have a process for versioning internal images, this adds one more thing to maintain, similar to how you'd think about caching strategies for shared API results — shared infrastructure saves duplication but adds a layer you have to manage deliberately.
Axis 3: Team Ownership and Blast Radius
A single multi-stage Dockerfile that handles dependency install, compilation, testing, linting, and final packaging is compact, but it's also a single point of merge conflict. When your frontend build steps and backend build steps live in the same file, a change from one team can break a build stage another team relies on.
Separate Dockerfiles create natural ownership boundaries. A Dockerfile.api and Dockerfile.worker can be owned by different teams, reviewed independently, and changed on different schedules without touching each other's PRs.
There's a review-clarity angle too. A 15-line Dockerfile is easy to review in a pull request. A 90-line multi-stage file with five stages, conditional build args, and platform-specific branches is the kind of file people rubber-stamp because nobody wants to trace through it line by line. Smaller, single-purpose files get better scrutiny.
A Decision Framework You Can Actually Use
| Situation | Recommended Approach |
|---|---|
| One service, one artifact, simple build | Single multi-stage Dockerfile |
| Multiple services sharing runtime/OS layer | Shared base image + per-service Dockerfile |
| Build and test have very different cache lifetimes | Separate Dockerfiles per concern |
| Different teams own different build steps | Separate Dockerfiles with clear ownership |
| Local dev speed matters more than CI purity | Multi-stage with a dev target (--target dev) |
That last row deserves a mention. Multi-stage builds support a --target flag, letting you stop the build at a specific stage. This is genuinely great for local development: define a dev stage with hot-reload tooling and dev dependencies, and a prod stage without them, in the same file. You're not fighting cache invalidation here because local builds usually run with your Docker daemon's local cache, not a cold CI cache pull. If you're wiring this up alongside something like Prometheus and Grafana in docker-compose for local observability, keeping the dev-target pattern in one file keeps your compose setup simple too.
Common Mistakes When Choosing a Structure
A few patterns show up again and again in real repos, and they're worth calling out directly.
Putting test execution inside the production build stage. If your final image build runs npm test or pytest as part of the Dockerfile, you're coupling test cache invalidation to production build cache invalidation. Move tests to their own stage or their own file, and only build the final image after tests pass in CI.
Copy-pasting base stages across five service Dockerfiles. This feels fine until a security patch needs to land everywhere at once. At that point you're grepping through files instead of bumping one version tag.
Over-engineering a one-service project with five stages. If you're the only maintainer and there's one deployable artifact, a lean two-stage Dockerfile (build, then runtime) is enough. Splitting further just adds files to maintain for no real benefit.
Ignoring .dockerignore regardless of structure. This one applies no matter which approach you pick. A bloated build context slows every stage down, multi-stage or not, because Docker has to send that context to the daemon before any caching logic even kicks in.
Where This Actually Lands
If you're a solo dev or a small team shipping one service, a multi-stage Dockerfile is the right default. It's one file, one source of truth, and the --target flag gives you dev and prod builds without duplication.
Once you have multiple services sharing infrastructure, or a team large enough that build changes cause merge friction, splitting into a shared base image plus per-service Dockerfiles pays for itself. The extra file isn't complexity for its own sake, it's a boundary that matches how your team actually works and how your CI cache actually behaves.
The mistake is picking one approach and applying it everywhere out of habit. Look at your cache invalidation patterns first, then your team's ownership lines, and let the Dockerfile structure follow from that instead of the other way around.