iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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/html
Try it Yourself »

Discussion

Loading…