Multi-Stage Docker Builds: Cut Your Image Size by 90% (With Real Examples)
If your production image is over a gigabyte and half of it is a compiler nobody runs at runtime, this is the fix. No new tools, just a different Dockerfile shape.
Run docker image ls on a typical single-stage Node or Go image and you'll usually see something in the 900MB–1.4GB range. Run the same build using multi-stage and you'll land somewhere between 80MB and 150MB, depending on your base image. Same application. Same functionality. The difference is almost entirely dead weight — build tools, source files, and package caches that got shipped into production by accident because nobody told the Dockerfile to leave them behind.
Multi-stage builds fix this by letting you run your build in one throwaway environment and copy only the finished artifact into a clean, minimal one. It's been a core Docker feature for years now, and if you're still writing single-stage Dockerfiles for compiled or bundled apps, you're carrying image bloat you don't need to carry.
Why Single-Stage Builds Get So Bloated
Think about what actually happens when you build a Node app the naive way. You start from a base image that already has the full Node toolchain — npm, node-gyp, sometimes a C compiler for native modules. You copy your source in. You run npm install, which pulls dev dependencies too unless you're careful. Then you run your build step, which generates a `dist` folder. And then... you ship the whole thing. Source files, `node_modules` including every dev dependency, build tool caches, all of it, sitting in the same image that goes to production.
The app itself might be 20MB of actual compiled output. Everything else in that image exists only because it was needed to *produce* that 20MB — not to *run* it. That's the core problem multi-stage builds solve: separating "what I need to build the thing" from "what I need to run the thing," which are very different lists.
The Basic Pattern
A multi-stage Dockerfile has more than one FROM instruction. Each one starts a new, independent build stage. You can name a stage with AS, do whatever heavy lifting you need in it, then in a later stage use COPY --from=<stage name> to pull just the files you actually need — nothing else from that stage comes along for the ride.
Here's the shift in plain terms: stage one is disposable. It exists purely to produce a file or folder. Stage two is what actually ships. Docker discards every layer from stage one automatically unless you explicitly copy something out of it — you don't need to clean anything up manually.
Example 1: Node.js — From ~1.1GB to ~150MB
The Bloated Version
This works fine functionally. It also ships the full `node:20` image (which includes a lot more than just the Node runtime), every dev dependency from `npm install`, your raw TypeScript source, and whatever build cache npm left behind. None of that is needed once `dist/server.js` exists.
The Multi-Stage Version
Two things worth calling out here. First, the runtime stage reinstalls dependencies with --omit=dev instead of just copying `node_modules` from the builder — that's deliberate, because the builder stage's `node_modules` includes dev dependencies you don't want in production. Second, `node:20-slim` in the final stage instead of the full `node:20` image cuts a meaningful chunk of size on its own, before multi-stage even enters the picture.
Example 2: Go — From ~900MB to ~15MB
Go is where multi-stage builds get almost absurdly effective, because a compiled Go binary has no runtime dependency on Go itself at all. Once it's compiled, you don't need the Go toolchain, standard library source, or anything else — just the binary and whatever OS-level libraries it links against (which, with `CGO_ENABLED=0`, can be zero).
That final image is essentially just your binary plus a handful of certs and base OS files distroless includes. No Go compiler, no shell, no package manager an attacker could use even if they got in. This is the version of "small image" that also happens to be a genuine security improvement, not just a disk-space one.
Base Image Choice Still Matters After You Go Multi-Stage
Multi-stage builds solve the "why is my build toolchain in production" problem. They don't automatically solve the "what's actually in my final base image" problem — that's a separate decision you still have to make for stage two.
| Base image type | Typical size | Has a shell? | Good fit for |
|---|---|---|---|
Full OS (e.g. node:20, golang:1.23) | 800MB–1.2GB | Yes | Build stages only, never final runtime |
Slim variant (e.g. node:20-slim) | 150–200MB | Yes | Runtime stage when you still need basic OS tools/debugging |
Alpine (e.g. node:20-alpine) | 50–120MB | Yes (ash) | Runtime stage, size-sensitive; watch for musl libc compatibility issues |
| Distroless | 2–20MB base | No | Compiled binaries (Go, Rust) where you need zero attack surface |
| Scratch | ~0MB base | No | Fully static binaries only, no dynamic linking at all |
slim is often the safer default unless you've specifically verified your dependencies work on musl.Naming Stages and Building a Specific One
Once you have more than two stages — say, a dependencies stage, a test stage, and a production stage — naming them and being able to target one specifically becomes genuinely useful, not just cosmetic.
This pattern is handy in CI specifically — you can fail fast on the test stage without paying for a full production build every time, and you're not duplicating the dependency install logic between "test image" and "prod image" since both branch off the same `deps` stage.
What You Actually Gain Beyond Disk Space
Faster deploys
Smaller images pull faster, which matters directly for rolling deploys and autoscaling events where new instances need to start quickly.
Smaller attack surface
No compiler, no source code, no dev dependencies sitting in production means fewer things an attacker can exploit if they get shell access.
Lower registry costs
Image storage and egress costs scale with size — this adds up fast across many services and many tags.
Cleaner separation of concerns
Your Dockerfile now documents, explicitly, what's needed to build versus what's needed to run — useful for anyone reading it later.
Mistakes Worth Watching For
- Copying `node_modules` wholesale from the builder stage instead of reinstalling with `--omit=dev` — this quietly reintroduces dev dependencies into your "lean" image, which defeats a chunk of the point.
- Forgetting `CGO_ENABLED=0` on a Go build that targets a distroless or scratch final image — without it, the binary can end up dynamically linked against glibc, which isn't present in those minimal base images, and the container fails at startup with a cryptic "no such file or directory" that has nothing to do with missing files.
- Not naming stages, which is fine with two stages but turns into a genuinely confusing Dockerfile once you're at four or five.
- Assuming Alpine is always the smallest/safest choice without checking whether your dependencies actually support musl libc.
- Copying more than you need in a `COPY --from` step — `COPY --from=builder /app ./` when you only actually need `/app/dist` still drags along everything else in that path.
Wrapping Up
If you're shipping a compiled or bundled application and your Dockerfile has exactly one `FROM` line, you're almost certainly shipping more than you need to. The fix isn't complicated — split the build environment from the runtime environment, copy across only the finished artifact, and pick a runtime base image that matches how minimal you actually need to go. The size difference alone is worth doing this for. The reduced attack surface is worth it even if you didn't care about size at all.
Frequently Asked Questions
Does a multi-stage build take longer than a single-stage one?
Not meaningfully. Docker still caches each stage's layers independently, so on a typical build where only your application code changed, the build stage's dependency installation is still cached exactly as it would be in a single-stage Dockerfile.
Can I use a different base image for each stage?
Yes, and you usually should — a full toolchain image for building, something minimal like slim, Alpine, or distroless for the final runtime stage. The stages don't need to share a base image at all.
What happens to files I don't explicitly copy with COPY --from?
They're discarded along with the rest of that stage once the build finishes. Nothing from an earlier stage ends up in your final image unless you explicitly copy it across.
Is distroless always better than Alpine for a minimal image?
It depends on what you're running. Distroless is excellent for statically compiled binaries (Go, Rust) where you don't need a shell at all. For interpreted runtimes like Node or Python, you generally still need some runtime environment present, so Alpine or slim variants tend to be the practical choice instead.
Why did my app break after switching to an Alpine-based final stage?
Most often it's a native dependency compiled against glibc that doesn't work against Alpine's musl libc. Check whether any of your dependencies use native bindings, and confirm they publish musl-compatible builds before assuming Alpine is a safe swap.
Can I have more than two stages?
Yes — there's no hard limit. Splitting into a shared dependency stage, a separate test stage, and a separate production stage, all named and targetable individually with --target, is a common and useful pattern once your build has more than one distinct purpose.
Comments