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 withAS <name>so reordering the file doesn’t break yourCOPY --from=0. - Unused stages are skipped. With BuildKit (the default builder),
docker build --targetonly builds stages the target depends on — so ateststage 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.confpulls 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
- 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.
- Attack surface shrinks. No shell, no compiler, no package managers in production means fewer CVEs and less to patch.
- Build caching gets better. Separating
COPY go.mod+go mod downloadfrom the source copy means dependency layers cache until your dependencies change — not your code. - Secrets and source stay out of production.
.envfiles, 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
--targetfor 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
COPYcommands 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 scratchis 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.