Docker Multi-Stage Build Security: A Practical Guide
Published July 15, 2026 · 9 min read
A Docker multi-stage build comes down to one security idea: nothing needed only to build the application should exist in the image you run in production. This guide covers the pattern end to end — splitting stages, handling secrets safely, and shipping a final image with the smallest possible footprint.

Why a single-stage build is a liability
A single-stage Dockerfile that installs a compiler, downloads source, builds, and then runs the result in the same image ships the compiler, the source, and every build-time dependency to production. None of that software executes at runtime, but all of it shows up in a vulnerability scan and all of it is available to an attacker who gets a shell.
Multi-stage builds separate “what it takes to build this” from “what it takes to run this,” and only the second list ships.
Step 1: Split the Dockerfile into build and runtime stages
Name each stage with AS and pull artifacts forward explicitly with COPY --from:
# Stage 1: build — full toolchain, discarded after build FROM golang:1.23 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server # Stage 2: runtime — only the compiled binary FROM gcr.io/distroless/static-debian12 COPY --from=build /out/server /server USER nonroot:nonroot ENTRYPOINT ["/server"]
The final image contains one binary and CA certificates. There is no Go toolchain, no source tree, and nothing an attacker could use to recompile or modify the running program from inside the container.
Step 2: Pass build secrets without leaking them into layers
ARG and ENV values are visible in the image history even in stages that get discarded, which makes them unsafe for tokens or private registry credentials. Use BuildKit's secret mount instead, which never writes the value to a layer:
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci --omit=dev
COPY . .
RUN npm run build
# Build with the secret supplied out-of-band:
# docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .The secret file exists only for the duration of that one RUN instruction and is never committed to a layer, so it cannot be extracted later with docker history even from the discarded build stage.
Step 3: Ship the smallest viable final stage
Once the build stage produces an artifact, the final stage does not need a full distribution — it needs whatever the artifact requires to run and nothing more:
# Static binary, no libc dependency: scratch is viable FROM scratch COPY --from=build /out/server /server COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/server"] # Dynamically linked binary or needs glibc: distroless FROM gcr.io/distroless/base-debian12 COPY --from=build /out/server /server ENTRYPOINT ["/server"] # Needs a shell for a health-check script or entrypoint wrapper: Alpine FROM alpine:3.20 RUN apk add --no-cache ca-certificates COPY --from=build /out/server /server ENTRYPOINT ["/server"]
Our Alpine vs Debian vs Distroless comparison covers the debugging and compatibility tradeoffs between these three options in more depth.
Build stage vs runtime stage — ScanRook v1.14.2, 2026-07-04
Step 4: Copy narrowly and run as a non-root user
COPY --from=build /out . pulls whatever is in that directory, including files you did not intend to ship. Copy individual paths, and set an explicit non-root user in the final stage:
FROM gcr.io/distroless/static-debian12 COPY --from=build --chown=nonroot:nonroot /out/server /server COPY --from=build --chown=nonroot:nonroot /app/config.yaml /config.yaml USER nonroot:nonroot ENTRYPOINT ["/server"]
Distroless images ship a nonroot user out of the box; for Alpine or Debian-based final stages, create one explicitly with RUN adduser -D -u 10001 app and switch to it before ENTRYPOINT.
Step 5: Apply the same pattern to Node and Python
The pattern is language-agnostic. For Node, the build stage installs full dependencies and bundles; the runtime stage installs only production dependencies:
FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:22-slim AS runtime WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist ./dist USER node CMD ["node", "dist/index.js"]
For Python, build wheels in the first stage and install only the wheels in the second, so compilers used for native extensions never reach the runtime image:
FROM python:3.13-slim AS build WORKDIR /app COPY requirements.txt . RUN pip wheel --wheel-dir /wheels -r requirements.txt FROM python:3.13-slim AS runtime WORKDIR /app COPY --from=build /wheels /wheels RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels COPY . . USER nobody CMD ["python", "app.py"]
Verifying stage separation actually worked
Confirm the build tools did not leak into the final image, then confirm the scan result reflects the smaller surface:
# Confirm no shell/compiler exists in a distroless or scratch final stage docker run --rm myapp:final sh -c "echo should not run" # Compare scan results between a single-stage and multi-stage build docker save myapp:single-stage -o single.tar docker save myapp:multi-stage -o multi.tar scanrook scan --file single.tar --format json --out single.json scanrook scan --file multi.tar --format json --out multi.json jq '.summary' single.json multi.json
Expect the multi-stage build to drop findings tied to compilers, build-only libraries, and dev dependency trees entirely — those packages are simply absent from the image, not just unused. Our piece on installed-state scanning explains why scanning what is actually present, rather than what a manifest claims, is what makes this comparison meaningful.
Where ScanRook fits
Multi-stage builds reduce the theoretical attack surface; scanning confirms the reduction actually happened. ScanRook reads the packages genuinely installed in the final image layers, so a scan of a well-built multi-stage image and a badly-built one will show the difference in the numbers, not just in the Dockerfile. See our container security checklist or the docs to wire scanning into your build pipeline.
Frequently asked questions
How do multi-stage builds improve security?
They keep compilers, build tools, and source code out of the shipped image, removing them as both an attack surface and a source of scan findings.
How do I pass secrets into a multi-stage build safely?
Use BuildKit's --mount=type=secret, not ARG or ENV, which persist in image history.
Does the build stage end up in the final image?
No, as long as you copy specific files with COPY --from rather than building on top of the build stage itself.
Does this work for Node and Python, not just compiled languages?
Yes — build wheels or bundles in the first stage, install only production dependencies in the runtime stage.
Confirm the reduction with ScanRook
Scan your single-stage and multi-stage builds side by side and see exactly which packages the split removed.