Multi-stage Builds
Multi-stage Docker builds copy artifacts between stages so build tools (compilers, package managers, source code) never reach the runtime image. Smaller images = faster pulls, smaller CVE surface, faster cold starts.
Build, runtime, cache, distroless
EXAMPLE
# 1) Naive single-stage (DON'T ship)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# Problems:
# • 1 GB image — Node + npm + git + build tools all in production
# • All source code ships to prod (including test files, .git, secrets in env)
# • Cache invalidated by every source change
# 2) Multi-stage — build vs runtime
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Stage names (AS deps, AS build, AS runtime) let you target with --target.
# Final image = ONLY the runtime stage layers.
# 3) Building + targeting
docker build -t myapp:1.0 .
docker build -t myapp:dev --target build . # stop after build stage (debugging)
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 --push .
# 4) Go example — static binary on scratch
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/server"]
# Final size: ~10 MB. No shell. No package manager. Tiny CVE surface.
# 5) Python example — slim runtime
FROM python:3.12-slim AS build
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/*.whl
COPY . .
USER 1000:1000
CMD ["python", "main.py"]
# 6) Multi-stage cache hits
# Stage A: deps — re-runs only when package-lock.json changes
# Stage B: build — re-runs when source changes
# Stage C: runtime — minimal, fast to assemble
# Layer order: most stable -> most variable. COPY package*.json BEFORE COPY source.
# 7) Reuse build artifacts in CI
# Push intermediate stages to a registry as cache:
docker buildx build \\
--cache-from type=registry,ref=myreg/myapp:buildcache \\
--cache-to type=registry,ref=myreg/myapp:buildcache,mode=max \\
-t myapp:1.0 --push .
# Multi-step builds in CI go from 6 minutes to 60 seconds with warm cache.
# 8) Heredocs (BuildKit) — readable RUN blocks
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
RUN <<EOF
set -euo pipefail
apk add --no-cache git
npm ci
npm run build
rm -rf /app/.git
EOF
# 9) Mounts at build time — secrets without leaking layers
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
# docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
# Caches that don't bloat the image
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/var/cache/apt apt-get install -y curl
# 10) Avoid root + add health checks in runtime stage
USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
CMD wget -qO- http://localhost:3000/healthz || exit 1
# 11) Multi-arch builds — same Dockerfile, multiple targets
docker buildx create --name multi --use
docker buildx build --platform linux/amd64,linux/arm64 -t myreg/myapp:1.0 --push .
# Useful for AWS Graviton + Mac M-series + cloud-build matrices.
# 12) Common bugs
# • COPY --from=build /app /app — overwrites WORKDIR with build's deps + source; be specific
# • Forgot --omit=dev when copying node_modules — dev tools ship to prod
# • Build stage installs system packages but runtime has none — use the same FROM family or copy specific deps
# • Alpine + native modules (sharp, bcrypt) — sometimes missing libc; use debian-slim if you hit segfaults
# • Running as root — easy escape vector; USER non-root in runtime stage
# • No HEALTHCHECK — orchestrator can't tell when app is ready
# • Single-stage with curl + git + jq + python — production bloat; multi-stage and strip
# • Pinning base without digest — base tag moves between builds; use FROM image@sha256:...
Why it matters
Multi-stage builds keep compilers and lockfiles out of the runtime image — ship the artifact, not the toolchain. Use BuildKit cache mounts to keep builds fast, run as a non-root user, pin bases by digest for reproducibility, and reach for distroless or scratch when you can compile a static binary.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
FROM node:20 AS build COPY . . RUN npm ci && npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/htmlTry it Yourself »
Discussion
Loading…