Docker Multi-Stage Builds: Slim Images, Faster Deploys, Fewer Vulnerabilities

August 10, 2026 · 8 min read

Your Go binary is 15 MB. Your Python app needs three pip packages. Yet somehow your Docker image is 1.2 GB and your CI pipeline takes 11 minutes. If this sounds familiar, you're shipping your build toolchain in your production image — compilers, package managers, headers, and caches that your runtime never uses.

Multi-stage builds fix this by letting one Dockerfile describe several stages, each with its own base image. Only the final stage ends up in your registry. Everything else — the 2 GB node_modules, the Go toolchain, the pip wheel cache — exists only during the build and then disappears.

1. The Problem: One Image to Build and Run

A naive Dockerfile for a Go service looks like this:

FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]

It works. It also ships the entire Go toolchain — compilers, standard library sources, git — in every production image. The image is 850 MB, contains hundreds of CVEs you don't need, and takes forever to pull on a cold node. The same pattern hits every language: FROM python:3.12 includes pip, setuptools, and a compiler toolchain you might not need; FROM node:20 ships npm and build headers.

2. The Fix: Multi-Stage Builds

The FROM instruction can appear multiple times. Each one starts a new stage. Only the last stage determines the final image. You copy artifacts from earlier stages with COPY --from:

# Stage 1: build the binary with the full toolchain
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server .

# Stage 2: runtime — only the binary
FROM alpine:3.20
RUN adduser -D appuser
COPY --from=builder /out/server /usr/local/bin/server
USER appuser
EXPOSE 8080
CMD ["server"]

That 850 MB image becomes roughly 15 MB. The binary was built once in stage 1 with full tooling, then copied alone into a minimal Alpine runtime. CGO_ENABLED=0 produces a static binary with no libc dependency — the classic trick that makes the final image work on any Linux base.

The rule: build in a fat image, run in a thin one. The build stage is throwaway; only the runtime stage ships.

3. Cutting Even Further: Distroless and Scratch

Alpine is small, but it still carries a package manager and shell — attack surface you don't need for a static binary. Google's distroless images contain only the runtime dependencies: no shell, no package manager, no wget. For a fully static Go binary, you can even go down to scratch:

FROM scratch
COPY --from=builder /out/server /server
USER 10001
EXPOSE 8080
ENTRYPOINT ["/server"]

That's a 10 MB image with exactly two files: the binary and a passwd entry for user 10001. Nothing to exploit, nothing to patch. The trade-off: no shell means docker exec debugging is limited — you diagnose via logs and metrics instead. For stateful troubleshooting, distroless with a debug variant is a pragmatic middle ground.

For interpreted languages, multi-stage still pays off — you just copy dependencies instead of binaries:

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY app.py .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]

The pip cache, wheel cache, and any build-only packages stay in the builder stage. The runtime image gets exactly the installed packages — no pip install at runtime, no build tools.

4. Caching: The Difference Between 2 Minutes and 11

Multi-stage doesn't automatically make builds fast — layer caching does. Docker caches a layer only if nothing above it changed. So order your Dockerfile by dependency stability:

One more trap: COPY . . before RUN go mod download invalidates the dependency cache on every commit. Move the manifest copy first — it's the single highest-impact fix for slow Docker builds.

5. Build Args and Target Selection

Two flags make multi-stage Dockerfiles flexible. --target lets CI build intermediate stages:

docker build --target builder -t myapp:build .
docker build -t myapp:latest .

And ARG propagates build-time values (versions, registry mirrors) without hardcoding:

ARG GOLANG_VERSION=1.22
FROM golang:${GOLANG_VERSION} AS builder

Use --build-arg to override at build time. Remember: ARG is build-time only — it never survives into the final image. For runtime configuration, use environment variables.

Summary

Start with one service. Rewrite its Dockerfile as multi-stage, check the image size and scan results, then roll the pattern out to the rest of your fleet. Smaller images pull faster, deploy faster, and give attackers less to work with — three wins from one Dockerfile change.