Using Docker Multi‑Stage Builds to Create Small, Secure Production Images
Learn how multi‑stage Dockerfiles separate build‑time tools from runtime artifacts, shrink image size, and reduce attack surface with a concrete Go example.
04 Mar 2026, 11:10 UTC

Why use multi‑stage builds
Multi‑stage builds let you define more than one FROM instruction in a single Dockerfile. Each stage starts from a clean base image, compiles or prepares your application, and then you copy only the needed artifacts into a final stage. The result is a production image that contains just the runtime binary (or interpreter and libraries) without compilers, package managers, or build‑time dependencies. This reduces image size, shortens pull times, and shrinks the attack surface.
Worked example: building a Go application
The following Dockerfile shows a two‑stage build for a simple Go program. The first stage uses the official Go image to compile the binary; the second stage uses a distroless image that contains only the static C library and no shell or package manager.
# ---------- Builder stage ----------
FROM golang:1.22-alpine AS builder
# Install git (required by go mod download) and set workdir
RUN apk add --no-cache git
WORKDIR /src
# Copy go.mod and go.sum first to leverage Docker cache
COPY go.mod go.sum .
RUN go mod download
# Copy the source code and build the binary
COPY . .
RUN go build -ldflags="-s -w" -o /app/myapp .
# ---------- Runtime stage ----------
FROM gcr.io/distroless/static:nonroot
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/myapp /app/myapp
# Use a non‑root user (distroless images already run as non‑root by default)
USER nonroot:nonroot
# Set the entrypoint to the binary
ENTRYPOINT ["/app/myapp"]
Save this as Dockerfile in the root of your Go project, then run the build command on a host with Docker Engine installed (you need to be in the docker group or use sudo).
# Build the image, tagging it as myapp:latest
# Run from the directory containing the Dockerfile
# Permission: user must be able to execute docker daemon commands
# Risk: a malformed Dockerfile can cause the build to fail; no system changes occur until you run the image.
docker build -t myapp:latest .
Verifying the result
- Check image size – compare the multi‑stage image with a naïve single‑stage build that uses
golang:1.22-alpinefor both build and runtime. - Inspect layers – confirm that only the runtime binary and distroless layers appear in the final image.
- Run the container – ensure the application starts and works as expected.
# Multi‑stage image size
docker image ls myapp:latest
# Single‑stage image size (for reference)
docker build -t myapp-single:latest -f Dockerfile.single .
docker image ls myapp-single:latest
You should see the multi‑stage image significantly smaller (often ~5‑10 MB vs. ~300 MB for the Go‑based single stage).
docker history myapp:latest
# Or use a visual tool like dive (if installed)
dive myapp:latest
The history should show a layer copying /app/myapp from the builder stage and then the distroless base layers; no Go compiler or apk packages should be present.
docker run --rm myapp:latest
# Replace with any command‑line arguments your program expects
If the binary is a web server, you can also publish a port and curl the endpoint to verify functionality.
Limits and considerations
- Isolation between stages – each stage starts with a clean filesystem. Anything you do not explicitly copy with
COPY --from=<stage>is unavailable in later stages. You cannot, for example, rely on a/tmpdirectory created in the builder stage persisting to the runtime stage. - No shared mounts or networks during build – Docker does not allow you to mount a host directory or attach a network to an intermediate stage and then use it in another stage. All data must be copied explicitly.
- Potential for larger images – if you accidentally copy large build‑time assets (e.g., node_modules, .git directories, or static documentation) into the final stage, the size benefit disappears. Always audit what you copy.
- Debugging difficulty – the final image often lacks a shell or package manager (as in the distroless example). If you need to troubleshoot inside the container, add a separate debug stage that includes
bashorapktools, but do not promote that stage to production. - Increased Dockerfile complexity – poorly named stages (e.g.,
AS buildervs.AS build) can lead to copy‑from errors that only surface at build time. Use clear, consistent names and comment each stage’s purpose.
Common mistakes to avoid
- Missing the
--fromflag – writingCOPY /app/myapp /app/myappwill try to copy from the build context, not from a previous stage, causing a "no such file" error. - Ambiguous stage references – if you rename a stage but forget to update the
COPY --fromline, the build fails. Keep stage names unique and update all references together. - Assuming cached dependencies persist – layers from earlier stages are not reused unless you explicitly copy their outputs. Changing a
COPYin the builder stage will invalidate the cache for that stage, but it will not affect later stages unless you also change what you copy from them. - Forgetting a non‑root user – even if you use a distroless base, some base images still run as root by default. Always set
USER (or verify the base image’s default) to limit privileges.
Practical checklist
- Name each stage with
AS <meaningful-name>. - Copy only the artifacts you need into the final stage using
COPY --from=<stage> <src> <dest>. - Verify image size with
docker image lsbefore and after adding stages. - Inspect layers with
docker historyor a tool likediveto confirm no unnecessary build tools remain. - Run the container and test the application’s core functionality.
- If debugging is required, add a separate debug stage (e.g.,
FROM golang:1.22-alpine AS debug) and keep it out of production tags.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.