Skip to content
Blog

Docker Multi-Stage Builds: Smaller Images From First Principles

How multi-stage Docker builds shrink images, cut attack surface, and improve caching — with real examples for Go, Node.js, and Python.

Published on • October 11, 2026

AI Assistant

A Go binary needs 20 MB. Your golang base image, toolchain, and source tree? Over 800 MB — and all of it ends up in production if you build naively. Multi-stage builds solve this with one elegant idea: build in one image, ship only the artifacts in another.

The problem multi-stage builds solve

Classic Dockerfile optimization is a series of hacks:

# The old playbook (fragile and leaky)
FROM golang:1.26
COPY . .
RUN go build -o /app && \
    rm -rf /tmp/* ~/.cache /src && \   # manually delete build junk
    apt-get purge -y gcc                # ...if you remember
CMD ["/app"]

Every hack has a flaw: deleted files still exist in earlier layers, manual cleanup is incomplete by definition, and the dev toolchain sits in your production image forever — expanding both pull times and your CVE scan results.

Multi-stage builds replace the hacks with architecture.

How it works

You write multiple FROM instructions in one Dockerfile. Each starts a new stage. You copy only what you need from earlier stages with COPY --from, and the final stage is the only thing that becomes your image.

# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/server ./cmd/server

FROM alpine:3.20
RUN apk --no-cache add ca-certificates
COPY --from=build /bin/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]

Build it: docker build -t myapp .

The result: a production image with a static binary, CA certs, and nothing else. No Go SDK, no compiler, no source code, no rm -rf gymnastics. The toolchain stage exists only during docker build.

Key mechanics:

  • Stages are numbered from 0 at the first FROM; name them with AS <name> so reordering the file doesn’t break your COPY --from=0.
  • Unused stages are skipped. With BuildKit (the default builder), docker build --target only builds stages the target depends on — so a test stage that isn’t an ancestor of your final stage never runs.
  • You can copy from external images too: COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf pulls that image just to grab one file.

A Node.js example: devDependencies never ship

# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci                         # full install, including dev deps
COPY . .
RUN npm run build                  # vite/webpack/tsc output in dist/

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The build stage can have every dev tool in the world. The runtime stage gets production dependencies and the compiled assets — a smaller image and a smaller attack surface.

A Python example: no compilers in production

# syntax=docker/dockerfile:1
FROM python:3.12 AS build
WORKDIR /src
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=build /wheels /wheels
COPY --from=build /src/requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
    && rm -rf /wheels
COPY . .
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

Wheels — including any with C extensions that needed compilers to build — are compiled in the build stage and installed offline in the slim runtime stage.

The practical payoffs

  1. Image size drops dramatically. Go: ~800 MB → ~25 MB. Node with devDeps: ~1.2 GB → ~300 MB. That’s faster pulls, faster deploys, faster autoscaling.
  2. Attack surface shrinks. No shell, no compiler, no package managers in production means fewer CVEs and less to patch.
  3. Build caching gets better. Separating COPY go.mod + go mod download from the source copy means dependency layers cache until your dependencies change — not your code.
  4. Secrets and source stay out of production. .env files, SSH keys, and internal code used during the build never make it into a registry image.

Patterns and gotchas

  • Name your stages (AS build) — integer references (--from=0) rot the moment someone reorders the file.
  • Use --target for intermediate goals. docker build --target build . gives you a stage with debug symbols and tooling for local troubleshooting, while the default build produces the lean production image.
  • Multi-stage doesn’t fix cache invalidation by itself — order your COPY commands from least- to most-frequently-changing (manifests before source), exactly as you would in any Dockerfile.
  • BuildKit is your friend. It skips stages your target doesn’t depend on, supports heredoc syntax, and is the default builder in modern Docker.
  • FROM scratch is a valid base. For a static Go or Rust binary, the final stage can start from nothing — the smallest possible image.

When you don’t need it

A single FROM alpine running a shell script needs no multi-stage build. The pattern pays off whenever compilation, dependency installation, or asset bundling happens during the build — which is most modern applications.

Multi-stage builds are documented in the Docker multi-stage builds guide. Adopt the pattern once and every future Dockerfile you write will produce smaller, safer, faster-to-ship images.