What is a multi-stage build, and what problem does it solve?

7 minintermediatemulti-stage-buildsdockerfileimage-size

Quick Answer

A multi-stage build uses multiple FROM instructions in a single Dockerfile, each starting a new, independent build stage. You use a full-featured image with compilers and build tools in an early stage, then copy only the final compiled artifacts into a separate, minimal final stage. This solves a common problem: a production image otherwise bloated with an entire toolchain — compilers, build dependencies, source code — that's only needed to produce the application, not to run it.

Detailed Answer

The problem: build tools bloat the final image

# Single-stage build -- the final image includes EVERYTHING used to build it
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]

This works, but the resulting image includes the entire Go toolchain: the compiler, standard library source, and build caches. That adds up to hundreds of megabytes, even though the running application, once compiled, is just a single small, statically-linked binary. None of that build tooling is needed at runtime — and it's not even wanted, since it also increases the attack surface.

The multi-stage solution

# Stage 1: "builder" -- has the full toolchain, produces the compiled binary
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o server .

# Stage 2: the FINAL image -- minimal, only what's needed to RUN the binary
FROM alpine:3.19
COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"]

COPY --from=builder reaches back into the first stage's filesystem and copies out just the compiled server binary. None of the Go compiler, source code, or build-time dependencies from the builder stage make it into the final image. The final image can be a tiny base — even scratch, an entirely empty base image, for a fully static binary with no runtime dependencies. This often shrinks the final image from hundreds of megabytes down to tens of megabytes or less.

Multiple intermediate stages

FROM node:20 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM nginx:alpine AS final
COPY --from=build /app/dist /usr/share/nginx/html

Stages can be named — AS deps, AS build — and referenced by name in later COPY --from= instructions. This is useful for separating concerns (installing dependencies vs. building vs. the final runtime image), even for languages that don't produce a single standalone compiled binary like Go does. Note the final stage here even uses a completely different base image, nginx:alpine, than the build stages, node:20. It just needs to serve the already-built static files, with no Node.js runtime required.

Why this matters beyond just image size

  • Reduced attack surface. A smaller final image, with no compilers, build tools, or source code present, gives a compromised container fewer things to exploit or exfiltrate.
  • Faster pulls and deployments. A smaller image transfers faster to every node that runs it, speeding up deployments and autoscaling at real scale.
  • A single Dockerfile, still. Before multi-stage builds existed, this "build in one environment, run in a minimal one" pattern needed a different approach — two separate Dockerfiles with manual artifact copying via a shared volume or script, or building outside Docker entirely and COPYing a pre-built artifact in. Both are more awkward and error-prone than expressing the whole pipeline in one file.