Best practices

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.

Multi-stage Docker build security

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

Horizontal bar chart of total ScanRook findings per image: busybox 1.37 has 2, alpine 3.20 301, golang 1.23-alpine 379, golang 1.23 18,152CriticalHighMediumLowNo severity assignedbusybox:1.372 total · 0 criticalalpine:3.20301 total · 20 criticalgolang:1.23-alpine379 total · 23 criticalgolang:1.2318,152 total · 568 critical
This is what shipping your build stage costs: golang:1.23, the image you compile in, reports 18,152 total findings (568 critical). golang:1.23-alpine reports 379 (23 critical), alpine:3.20 reports 301 (20 critical), and busybox:1.37 reports 2. A multi-stage build does not fix a single one of those CVEs — it decides which row you ship. The compiler, headers and package manager stay in the builder, where they are never part of the running attack surface. Bar length is linear in total findings, so the smallest bars are only a few pixels wide — exact totals are printed at right. The four rated buckets do not always add up to the total because some advisories carry no CVSS severity; that remainder is the unfilled part of each bar. The busybox:1.37 scan was partial — its runtime package inventory was unavailable, so matching fell back to heuristics. busybox genuinely is minimal, but treat 2 as a floor rather than a verified complete count.

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.

Related Posts

More on this topic.