Reduce Docker Image Size and Attack Surface with Multi-Stage Builds
Convert a single-stage Dockerfile to multi-stage builds to strip compilers, package managers, and source code from your runtime image. This guide covers stage naming, minimal base selection, verification commands, and common failure recovery.
18 Oct 2025, 12:48 UTC

Desired Outcome
Produce a minimal runtime container image that contains only the compiled application and its runtime dependencies, excluding compilers, package managers, build tools, and source code. This reduces image size, pull time, and the attack surface available to an attacker who gains access to the running container.
Prerequisites
- Docker Engine 17.05 or later (run
docker versionto confirm). - A working single-stage Dockerfile that builds your application successfully.
- Familiarity with
COPY,RUN, andFROMinstructions. - Optional: BuildKit enabled (
DOCKER_BUILDKIT=1) for improved layer caching and parallel stage execution.
Procedure: Convert a Single-Stage Build to Multi-Stage
1. Identify Build-Time vs Runtime Dependencies
List every package, tool, and file required only during compilation. Typical build-time items include:
- Compilers (gcc, go, javac, node-gyp)
- Package managers (apt, apk, npm, maven, gradle)
- Development headers and static libraries
- Test frameworks and linters
- Source code and intermediate build artifacts
Runtime dependencies are typically a small subset: the compiled binary, shared libraries it links against, configuration files, and a minimal shell or entrypoint script.
2. Choose a Minimal Runtime Base Image
Select a base image that provides only the runtime environment. Common choices:
- Debian/Ubuntu slim —
debian:bookworm-slimorubuntu:24.04(smaller than full variants, still glibc-based). - Alpine Linux —
alpine:3.20(musl libc, ~5 MB base). Verify your binaries are compatible with musl. - Distroless —
gcr.io/distroless/base-debian12or language-specific variants (no shell, no package manager, smallest attack surface).
3. Restructure the Dockerfile with Named Stages
Use AS to name each stage. This makes COPY --from= references stable against reordering.
# Stage 1: Build environment
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/myapp ./cmd/myapp
# Stage 2: Minimal runtime
FROM gcr.io/distroless/base-debian12
COPY --from=builder /out/myapp /myapp
ENTRYPOINT ["/myapp"]
Key points:
- The
builderstage uses the full Go toolchain image. CGO_ENABLED=0produces a statically linked binary that runs on distroless/Alpine without glibc.- The final stage copies only the
/out/myappbinary from the builder stage. - No shell, package manager, or Go toolchain exists in the final image.
4. Handle Non-Binary Artifacts
If your application needs configuration files, templates, or static assets at runtime, copy them explicitly from the builder stage or a dedicated asset stage:
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=assets /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
5. Use --target for Local Iteration
Build and test individual stages without executing the full pipeline:
# Build only the builder stage and run tests inside it
docker build --target builder -t myapp-builder .
docker run --rm myapp-builder go test ./...
# Build only the final stage (requires builder artifacts from cache)
docker build --target final -t myapp:latest .
Run these commands from the directory containing the Dockerfile. Requires standard Docker CLI permissions (user in docker group or root).
Expected Checks
Verify Build Tools Are Absent from Final Image
docker run --rm myapp:latest sh -c 'which gcc go npm || echo "build tools not found"'
Expected output: build tools not found (or command not found error). If sh is unavailable (distroless), use docker run --rm myapp:latest /myapp --version to confirm the binary runs.
Inspect Layer History
docker history myapp:latest --no-trunc
Confirm that layers containing go build, npm install, or apt-get install appear only in intermediate stages (shown as <missing> or with the builder stage's image ID), not in the final image's layers.
Compare Image Sizes
docker images myapp:single-stage myapp:latest
A typical Go service drops from ~800 MB (full golang image) to ~15 MB (distroless) or ~10 MB (Alpine). Node.js services often shrink from ~1 GB to ~50–100 MB.
Scan for Vulnerabilities
Run a scanner on the final image:
docker scout cves myapp:latest
# or
trivy image myapp:latest
Multi-stage builds reduce the package set, but the runtime base image still needs patching. Rebuild regularly.
Recovery Options and Common Failures
Broken COPY --from= References
If you rename or reorder stages without updating COPY --from=builder, the build fails with "invalid reference format" or "stage not found". Fix by synchronizing stage names and copy references.
Missing Runtime Dependencies
The final image may lack shared libraries required by dynamically linked binaries. Symptoms: error while loading shared libraries or not found on startup. Solutions:
- Statically link the binary (
CGO_ENABLED=0for Go,-staticfor C/C++). - Copy required libraries explicitly:
COPY --from=builder /lib/x86_64-linux-gnu/libc.so.6 /lib/. - Switch to a runtime base that provides the needed libc (e.g.,
debian:sliminstead of distroless).
Secrets Leaked in Intermediate Layers
If you copy a secret (SSH key, token) into the builder stage, it remains in that stage's layer history even if not copied to the final stage. Mitigation:
- Use BuildKit secret mounts:
RUN --mount=type=secret,id=ssh_key ...(requires BuildKit). - Never bake secrets into any stage; inject at runtime via environment variables or mounted volumes.
Slow Local Builds
Building all stages on every change slows iteration. Use docker build --target builder to rebuild only the compilation stage. Enable BuildKit (DOCKER_BUILDKIT=1 docker build ...) for better cache reuse across stages.
Limitations
- Multi-stage builds do not replace
.dockerignore; excludenode_modules,.git, and build outputs from the build context to keep context transfer fast. - They do not eliminate vulnerabilities in the runtime base image; schedule regular base image updates.
- They do not produce multi-architecture images automatically; use
docker buildxforlinux/amd64,linux/arm64manifests. - Distroless images lack a shell, making debugging harder; consider
gcr.io/distroless/base-debian12:debug(includes busybox) for troubleshooting, then switch back to non-debug for production.
Practical Verification Checklist
- Run
docker version— confirm Engine ≥ 17.05. - Build multi-stage image:
docker build -t myapp:multi-stage . - Check size:
docker images myapp:multi-stage— expect 10–100 MB for most services. - Run
docker history myapp:multi-stage— verify build tools only in intermediate layers. - Execute
docker run --rm myapp:multi-stage <health-check>— confirm application starts and serves traffic. - Scan:
trivy image myapp:multi-stage— review only runtime-layer findings.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.