Slimming Production Containers with Docker Multi‑Stage Builds
Learn how Docker multi‑stage builds separate compile‑time and run‑time environments to cut production image size by half or more, with a concrete Go example and practical verification steps.
06 Jun 2026, 13:07 UTC

The problem: bulky images slow down deployments
When you package an application directly in a single Dockerfile, the final image often contains the build tools, compilers, and intermediate files you only needed during compilation. That bloat can push image sizes to several hundred megabytes, lengthening pull times, increasing storage costs, and expanding the attack surface.
Thesis: multi‑stage builds let you keep only what you need to run
By defining multiple FROM statements in one Dockerfile, each stage can start from a different base image. You compile or assemble artifacts in an early stage, then copy just the required binaries or files into a final stage that uses a minimal runtime base (e.g., scratch or alpine). Unused layers from the builder are discarded, yielding a lean production image.
Worked example: a Go web service
Assume a simple Go program main.go that listens on port 8080. The Dockerfile below shows two stages:
# 1️⃣ Builder stage – uses the full Go toolchain
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# 2️⃣ Runtime stage – starts from scratch
FROM scratch AS runtime
COPY --from=builder /app/main /main
EXPOSE 8080
ENTRYPOINT ["/main"]
To build the image, run (you need permission to talk to the Docker daemon; on many systems this means using sudo or being in the docker group):
docker build -t myservice:latest .
After the build finishes, inspect the size:
docker images myservice
You should see an image in the low‑single‑digit megabyte range (often < 10 MB) because only the compiled binary resides in the final scratch layer. For comparison, a single‑stage Dockerfile that installs the Go SDK and builds in the same layer typically produces images >150 MB.
Verifying caching behavior
Make a tiny change to main.go (e.g., adjust a log message) and rebuild:
docker build -t myservice:latest .
The build output will show that the builder stage is re‑executed (you see Step 2/... : COPY . . and the RUN go build line), while the runtime stage reports Using cache and finishes instantly. This demonstrates that only the affected stage is rebuilt, saving CI time.
Trade‑offs and limitations
- Context size matters: If your build directory contains large unrelated files (logs, node_modules, etc.), each stage still copies them unless you prune with a
.dockerignore. A poorly maintained ignore file can erase the speed benefits. - Layer history remains: The final image still reflects the layers you copied from the builder; it is not automatically squashed. If you need the absolute minimum layer count, you can add BuildKit’s
--squashflag or use tools likedocker-squash. - Base image compatibility: Some runtime bases (like
scratch) lack common utilities (e.g.,ca-certificates). If your binary needs SSL roots or glibc, you may need to copy those files explicitly or choose a slightly larger base likealpine.
Actionable closing
Start small: pick one service, add a builder stage that compiles your artifact, and a runtime stage that copies only the binary (or the minimal set of files you measured with docker run --rm -v $(pwd):/src yourimage ls -la). Measure the before/after size with docker images and track pull times in your CI pipeline. If you hit the limitations above, adjust .dockerignore or consider a squash step. Over time, applying multi‑stage builds across your portfolio will consistently shrink images, accelerate deployments, and reduce the surface you need to secure.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.