Shrinking Production Images with Docker Multi-Stage Builds
Learn how to use Docker multi-stage builds to separate build-time dependencies from runtime artifacts, reducing image size and improving security.
18 Sept 2025, 01:40 UTC

The Bloat Problem in Container Images
A common friction point in containerized deployments is the "bloat" of production images. When you build an application, you need a heavy toolchain: compilers, build-time dependencies, header files, and package managers. If you use a single-stage Dockerfile, all those tools remain in the final image. This results in images that are hundreds of megabytes larger than necessary, slower to pull across a network, and—more critically—larger attack surfaces for potential exploits.
The solution is the multi-stage build. This pattern allows you to use one image for compiling your code and a completely different, minimal image for running it, discarding the build tools entirely in the process.
How Multi-Stage Builds Work
Multi-stage builds use multiple FROM statements in a single Dockerfile. Each FROM instruction begins a new stage of the build. You can name these stages using the AS keyword, which allows you to selectively copy artifacts from one stage to another.
The key mechanism is the COPY --from<stage> command. Instead of copying files from your local host, Docker copies files from the filesystem of a previous build stage. Once the final stage is completed, Docker discards all previous stages. Only the layers in the final stage are saved to the image manifest.
Separating Build-Time Secrets
Beyond size, multi-stage builds improve security. When using BuildKit (the modern Docker build engine), you can use --mount=type=secret during the build stage. Because the final image only contains files explicitly copied over, these secrets—such as private SSH keys or npm tokens—never persist in the image layers, preventing accidental leaks in your container registry.
Worked Example: Go Application to Distroless
Consider a Go application. A full Go SDK image is large because it contains the entire toolchain. For the runtime, we only need the compiled binary. We will use gcr.io/distroless/static, a minimal image that contains only the most basic necessities (like CA certificates and timezone data) but no shell or package manager.
# syntax=docker/dockerfile:1
# Stage 1: The Builder
FROM golang:1.22-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
WORKDIR /app
# Cache dependencies separately to speed up rebuilds
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 ensures a statically linked binary
# that doesn't rely on system C libraries (glibc)
RUN CGO_ENABLED=0 GOOS=linux go build -o /myapp main.go
# Stage 2: The Runtime
FROM gcr.io/distroless/static
# Copy only the binary from the builder stage
COPY --from=builder /myapp /myapp
# Run as a non-root user for security
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]
Verification and Execution
Run the build on your local machine or CI runner with the following command:
docker build -t my-app:latest .
Verification: To confirm the build tools were discarded, run docker history my-app:latest. You should see only the layers from the distroless/static base and the single COPY command. You will not see the apk add or go mod download steps from the builder stage.
Trade-offs and Technical Limitations
While powerful, multi-stage builds introduce specific engineering challenges:
- Permission Mismatches: Files copied via
COPY --fromare owned byroot:rootby default. If your runtime image uses a non-root user (like thenonrootuser in distroless), you must useCOPY --from=builder --chown=nonroot:nonroot /myapp /myappto avoid permission denied errors. - The glibc Trap: If you use a minimal base like
scratchordistroless/static, your binary must be statically linked. If your app requires C libraries (CGO), you must either useCGO_ENABLED=0or switch the runtime base togcr.io/distroless/cc, which includes the necessary C runtime libraries. - Build Complexity: Over-splitting a build into too many stages can make the Dockerfile harder to maintain. Each stage is a fresh environment; if you forget to copy a required configuration file or asset from the builder to the runtime, the container will fail at startup.
Actionable Summary
To implement this pattern in your next project: start by identifying your build-time dependencies (compilers, git, npm) and your runtime requirements (the binary, static assets). Create a builder stage for the heavy lifting and a minimal runtime stage for the artifact. Use docker history to verify that your final image size is minimized and that no build-time tools remain in the production manifest.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.