Multi-Stage Docker Builds: Cut Your Image Size by 90% (With Real Examples)

Docker · Image Optimization · Dockerfile

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 Docker Builds: Cut Your Image Size by 90%

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.

Level: Beginner–Intermediate Read time: ~10 min Covers: Node.js, Go, general pattern

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.

Single-stage
~1.1 GB
Multi-stage
~120 MB

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.

Stage 1: full toolchain
Compile / build artifact
Toolchain + source discarded
Stage 2: only the artifact copied in

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

FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD ["node", "dist/server.js"]

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

# Stage 1: build environment — this stage never ships FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: lean runtime — this is the image that actually ships FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist CMD ["node", "dist/server.js"]

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).

# Stage 1: compile — full Go toolchain lives here only FROM golang:1.23 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server # Stage 2: distroless — no shell, no package manager, nothing extra FROM gcr.io/distroless/static-debian12 COPY --from=builder /server /server ENTRYPOINT ["/server"]

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 typeTypical sizeHas a shell?Good fit for
Full OS (e.g. node:20, golang:1.23)800MB–1.2GBYesBuild stages only, never final runtime
Slim variant (e.g. node:20-slim)150–200MBYesRuntime stage when you still need basic OS tools/debugging
Alpine (e.g. node:20-alpine)50–120MBYes (ash)Runtime stage, size-sensitive; watch for musl libc compatibility issues
Distroless2–20MB baseNoCompiled binaries (Go, Rust) where you need zero attack surface
Scratch~0MB baseNoFully static binaries only, no dynamic linking at all
Common mistake: switching straight to Alpine for a Node app and being confused when native modules break. Alpine uses musl libc instead of glibc, and some npm packages with native bindings (bcrypt, sharp, certain database drivers) either need a rebuild against musl or don't support it at all. If you hit strange native-module errors only in the Alpine image, this is almost always why — 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.

FROM node:20 AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS test COPY . . RUN npm test FROM deps AS build COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
# Run just the test stage in CI, without building the full production image docker build --target test -t myapp:test . # Build the production image separately when tests pass docker build --target production -t myapp:latest .

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.
Worth knowing: multi-stage builds don't cost you anything in build time on their own — Docker still only rebuilds a stage's layers when its inputs change, same caching rules as always. The build stage with the full toolchain can stay cached across runs exactly like it would in a single-stage Dockerfile; you're not paying a caching penalty for splitting things up.

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