Using Docker Multi‑Stage Builds to Shrink Images and Harden Containers
Learn how Docker multi‑stage builds let you compile with heavy tools and ship only the needed binary, cutting image size by 70‑90 % and improving container security.
07 Jul 2026, 16:27 UTC

Problem: bloated images slow down deployments and widen the attack surface
When you compile an application inside a Docker container, the build stage often pulls in heavy SDKs, compilers, and package managers. If you leave those tools in the final image, you end up with a large layer that takes longer to pull, consumes more disk space, and introduces unnecessary binaries that could be exploited.
Thesis: multi‑stage builds let you keep only the artifacts you need at runtime
Docker’s multi‑stage feature (available since Engine 17.05) lets you define several FROM statements in a single Dockerfile. Early stages can use a full‑featured build environment; later stages start from a minimal base (e.g., alpine or scratch) and copy only the compiled binary, static assets, or required libraries. The result is a smaller, more secure image without sacrificing build convenience.
Worked example: building a Go web service
The following Dockerfile shows a two‑stage build for a simple Go application. The first stage uses the official Golang image to compile the binary; the second stage runs from alpine and copies just the executable.
# ---------- Build stage ----------
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Cache go.mod download
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# ---------- Runtime stage ----------
FROM alpine:3.20
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/main .
USER appuser
EXPOSE 8080
CMD ["./main"]
To build the image:
docker build -t mygoapp:latest .
You can inspect the size difference:
docker image ls mygoapp
Typically the final image is under 10 MB, whereas a single‑stage build that keeps the Golang SDK would be 200‑300 MB.
Trade‑offs and debugging considerations
- Debugging is harder because intermediate stages are not present in the final image. If you need to examine build‑time dependencies, add a temporary stage or use the
--targetflag to stop at a specific stage:
docker build --target builder -t mygoapp:debug .
docker run --rm -it mygoapp:debug sh
This lets you run a shell inside the builder image to verify that the correct source files were copied or to run tests.
- Copying too much data can still bloat the final image. Each
COPY --from=...should be scoped to the exact files you need (e.g., a binary, a config directory, or a static asset folder). Wildcards likeCOPY . .in the runtime stage defeat the purpose.
Verification steps
- Check Docker version (server API ≥ 1.25 corresponds to Engine 17.05):
docker version --format '{{.Server.APIVersion}}'
- Build the image and note the size:
docker build -t verifyapp .
docker image ls verifyapp
- Compare with a single‑stage Dockerfile that omits the multi‑stage approach (you can keep this as a reference). The multi‑stage image should be significantly smaller.
- Use the
--targetflag to run an intermediate stage for debugging, confirming that only the expected files exist:
docker build --target builder -t verifyapp:builder .
docker run --rm verifyapp:builder ls -la /app
Actionable closing
Adopt multi‑stage builds as the default for any production Dockerfile where the build environment differs from the runtime environment. Pair the technique with a .dockerignore file to keep the build context small, and periodically re‑base your runtime stage to the latest minimal base image (e.g., alpine or distroless) to continue shrinking the attack surface. By separating concerns in a single Dockerfile you gain faster pulls, lower storage costs, and a cleaner security profile without adding complexity to your CI pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.