Docker Multi-stage Builds Explained
How Docker multi-stage builds work, why they shrink images dramatically, and patterns for Python, Node, and Go services that need a clean runtime layer.
What you'll learn
- ✓What a multi-stage build actually is
- ✓Why it matters for image size and security
- ✓Concrete Dockerfiles for Python, Node, and Go
- ✓Caching layers and order of operations
- ✓BuildKit features that take this further
Prerequisites
- •Basic Docker familiarity
What and why
A multi-stage Docker build uses multiple FROM statements in the same Dockerfile. Each FROM starts a new stage, and you can copy artifacts from one stage into another. The final image contains only the last stage; everything before it is discarded.
The win is size and security. Build toolchains (compilers, dev headers, npm caches) stay in the build stage. The runtime image carries only the compiled artifact plus its minimal dependencies. Smaller images push faster, scan cleaner, and have fewer CVEs.
Mental model
Think of the Dockerfile as a pipeline of intermediate filesystems. Each stage builds on top of a base, runs its commands, and ends up as a layered image. The final stage cherry-picks files from the others.
+----------------- Stage 1: builder ------------------+
| FROM python:3.12 AS builder |
| COPY pyproject.toml uv.lock . |
| RUN uv sync --frozen |
| COPY src/ ./src |
| RUN python -m build |
| produces: /app/dist/myapp-1.0-py3-none-any.whl |
+------------------------+----------------------------+
|
COPY --from=builder
|
v
+----------------- Stage 2: runtime ------------------+
| FROM python:3.12-slim |
| COPY --from=builder /app/dist/*.whl /tmp/ |
| RUN pip install --no-cache-dir /tmp/*.whl |
| CMD ["myapp"] |
+-----------------------------------------------------+
Final image = stage 2 only. Stage 1 layers are not pushed. Hands-on example
A production-grade Python Dockerfile:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
WORKDIR /app
ENV UV_LINK_MODE=copy
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ ./src
RUN uv build
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN useradd --uid 10001 --create-home app
COPY --from=builder /app/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl
USER app
EXPOSE 8000
CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]
The builder uses uv to resolve dependencies, builds a wheel, and discards everything else. The runtime is a slim Python base with only the wheel installed, running as a non-root user.
A Go example takes this further: the final stage is scratch because the binary is statically linked.
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
A Node Dockerfile splits dev dependencies from prod:
FROM node:20 AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:20 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build && pnpm prune --prod
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
CMD ["node", "dist/server.js"]
The build runs with full dev dependencies; the runtime ships only production node_modules and the compiled output.
Common pitfalls
Forgetting to order layers by change frequency. If you COPY . . before RUN install, every code change busts the dependency layer cache and reinstalls everything. Always copy lockfiles first, install, then copy the rest of the source.
Mixing base images across stages with incompatible glibc versions. If the builder uses debian:bookworm and the runtime uses alpine, dynamically linked artifacts will fail to start. Match base distros or build statically.
Leaving build tools in the final stage. apt-get install gcc followed by apt-get remove gcc does not actually shrink the image; the file is still in the layer. Use a separate builder stage instead.
Pushing the wrong stage. docker build . defaults to the last stage. If you want an earlier stage as the final image, use --target builder. CI scripts often pin the wrong target.
Running as root. The default user in most base images is root. Add a USER directive in the runtime stage; many vulnerability scanners flag images that ship as root.
Production tips
Use BuildKit cache mounts for package managers. They survive across builds without shipping into the image.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Use --mount=type=secret to pass credentials at build time without baking them into a layer.
Pin base image tags by digest in production. python:3.12-slim@sha256:... guarantees reproducibility; a moving tag means today’s image is not yesterday’s image.
Scan the final image with Trivy or Grype in CI. Multi-stage shrinks the surface area but does not eliminate CVEs in your base image.
Keep stages named (AS builder) and ordered by stability. Cache-friendly ordering can drop build times from minutes to seconds.
Use .dockerignore aggressively. Files you exclude never enter the build context, which speeds up the build and prevents secrets from leaking by accident.
Wrap-up
Multi-stage builds separate “build” from “run.” Compile in a fat stage, ship a thin one, copy only what you need between them. Order layers by change frequency, pin base images, drop root, and lean on BuildKit cache mounts. The final images are smaller, push faster, and present a smaller target for scanners and attackers.
Related articles
- Docker Docker Healthchecks and Restart Policies Explained
Healthchecks tell Docker if a container is alive. Restart policies tell it what to do when it is not. Together they keep your services running.
- Docker Docker Image Layer Caching Strategies for Faster Builds
Learn how Docker's layer cache really works and the ordering tricks that turn a 5-minute build into a 20-second one without sacrificing correctness.
- Docker Docker Networking: Bridge, Host, and Overlay Networks Explained
A clear guide to Docker's three most common network drivers, when to pick each one, and how packets actually flow between containers in real deployments.
- Docker Docker Security Best Practices for Production
Harden Docker images and runtimes: non-root users, minimal bases, secret handling, capability drops, and image signing - without making developers miserable.