Skip to content
C Codeloom
DevOps

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.

·5 min read · By Codeloom
Intermediate 10 min read

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.
Two-stage build: builder and runtime

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.