Docker Multi-Stage Builds Cheat Sheet
Docker multi-stage Dockerfile patterns covering build stages, COPY --from, caching, and minimal final images.
Multi-Stage Node.js Build
Build stage compiles TypeScript, runtime stage ships only production output.
# syntax=docker/dockerfile:1FROM node:20-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciFROM deps AS buildCOPY . .RUN npm run buildFROM node:20-alpine AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY --from=build /app/dist ./distUSER nodeEXPOSE 3000CMD ["node", "dist/main.js"]
Multi-Stage Go Build (Scratch Final Image)
Compile a static binary, then ship it with no OS layer at all.
FROM golang:1.23-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/serverFROM scratch AS runtimeCOPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/COPY --from=build /app /appENTRYPOINT ["/app"]
BuildKit Cache Mounts & Build-Time Args
Speed up rebuilds with persistent cache mounts and multi-target builds.
# syntax=docker/dockerfile:1FROM golang:1.23-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go mod downloadCOPY . .RUN --mount=type=cache,target=/root/.cache/go-build \ go build -o /app ./cmd/server# Build only the 'build' stage for debugging:# docker build --target build -t app:debug .# Build final stage (default):# docker build -t app:latest .
Key Directives & Flags
Instructions and CLI flags specific to multi-stage builds.
- FROM <image> AS <name>- names a stage so later stages can reference it
- COPY --from=<stage|image>- copies files from a previous stage or an external image, not the build context
- --target <stage>- `docker build --target <name>` builds only up to that stage
- --mount=type=cache- BuildKit cache mount that persists across builds without bloating the image layer
- FROM scratch / distroless- empty or near-empty base for the final stage, minimizes attack surface and size
- --mount=type=secret- injects a secret into a RUN step without leaving it in any image layer
docker buildx bake — Multi-Target, Multi-Platform Matrix
Drive multiple multi-stage build targets and platforms from a single declarative bake file instead of chaining docker build invocations.
// docker-bake.hclvariable "TAG" { default = "latest"}group "default" { targets = ["app", "app-debug"]}target "base" { dockerfile = "Dockerfile" platforms = ["linux/amd64", "linux/arm64"]}target "app" { inherits = ["base"] target = "runtime" tags = ["registry.example.com/app:${TAG}"] cache-from = ["type=registry,ref=registry.example.com/app:cache"] cache-to = ["type=registry,ref=registry.example.com/app:cache,mode=max"]}target "app-debug" { inherits = ["base"] target = "build" tags = ["registry.example.com/app:${TAG}-debug"]}# Build + push everything in the default group:# docker buildx bake --push
Build-Time Secrets Without Leaking Layers
Pass credentials into a RUN step via BuildKit's secret mount so they never land in an image layer or build cache history.
# syntax=docker/dockerfile:1FROM node:20-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./# Secret is mounted at /run/secrets/npm_token only for this RUN, then discardedRUN --mount=type=secret,id=npm_token \ NPM_TOKEN=$(cat /run/secrets/npm_token) \ npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && \ npm ciCOPY . .RUN npm run build# Build with:# docker build --secret id=npm_token,src=$HOME/.npm_token -t app .# In CI (GitHub Actions), pipe a masked env var instead of a file:# docker build --secret id=npm_token,env=NPM_TOKEN -t app .
Dedicated Test Stage as a CI Gate
Add a throwaway test stage between build and runtime so `docker build --target test` fails the pipeline before a bad image is ever pushed.
FROM node:20-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciFROM deps AS buildCOPY . .RUN npm run build# Not part of the final image graph -- only reachable via --target testFROM build AS testRUN npm run lint && npm run test -- --ciFROM node:20-alpine AS runtimeWORKDIR /appCOPY --from=build /app/dist ./distCMD ["node", "dist/main.js"]# CI pipeline:# docker build --target test -t app:test . # exits non-zero on failing tests# docker build --target runtime -t app:latest . # only runs if test stage passed separately
Advanced Build Flags & Semantics
Lesser-known Dockerfile/BuildKit behaviors that matter once you're past basic multi-stage layouts.
- ARG before first FROM- only visible to FROM lines themselves (e.g. picking a base image tag); must be re-declared with ARG inside a stage to use it in RUN/ENV
- COPY --link- copies independently of prior layers so the copy can be cached and reused even when earlier layers change, speeding up rebuilds
- COPY --chown=uid:gid- sets file ownership during copy in one layer instead of a separate RUN chown, avoiding an extra layer
- --mount=type=bind- mounts build-context files read-only into a RUN step without a COPY, useful for one-off scripts that shouldn't persist in the layer
- Empty/unused stages- a stage never referenced by --from or the default target is simply skipped by BuildKit, so intermediate 'tool' stages cost nothing in the final image
- --output=type=local,dest=./out- exports files from a stage straight to the host filesystem instead of producing an image, handy for extracting build artifacts only
- SOURCE_DATE_EPOCH- pinning this build arg produces reproducible image timestamps/layer digests across identical builds
Order COPY instructions from least to most frequently changed (dependency manifests before source code) so BuildKit's layer cache survives across builds where only your application code changed — a `COPY . .` before `npm ci` invalidates the dependency layer on every commit.