Multi-stage builds
How to keep production images small by separating build-time tools from what actually ships.
The problem with a single-stage build
Take a typical frontend app built with something like React or Vue. Building it requires a full Node.js toolchain —npm, all your devDependencies, a bundler, maybe TypeScript. But once the build finishes, the output is just a folder of static HTML/CSS/JS files that any plain web server (like nginx) can serve. None of the build tooling is needed at runtime.
If you write a single-stage Dockerfile the naive way:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npx", "serve", "dist"]
devDependency, all your source files, and the build output — often several hundred MB — to serve what's really just a handful of static files. That's wasted disk space, slower pulls, a larger attack surface, and unnecessarily long deploy times.
The fix: multiple FROM stages in one Dockerfile
A multi-stage build lets you use one image to build the app, then copy only the finished output into a second, much smaller image that's what actually ships:
# Stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Two things make this work:
AS buildnames the first stage so it can be referenced later.COPY --from=build /app/dist /usr/share/nginx/htmlcopies files from the named earlier stage into the current one. Everything else from Stage 1 — Node.js itself,node_modules, source files — is left behind and never makes it into the final image.
FROM nginx:alpine, so it only contains nginx and the static build output. The Node.js toolchain that produced that output never ships at all.
Same idea, a compiled language
Multi-stage builds are arguably even more valuable for compiled languages, where the build toolchain (a compiler, headers, etc.) is completely unnecessary at runtime. A Go example:
FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM alpine:3.20
COPY --from=build /app/server /server
EXPOSE 8080
CMD ["/server"]
golang:1.22 build image is close to a gigabyte with the full compiler toolchain, while the final alpine-based image can be under 20MB — just the compiled binary and a minimal base OS.
Why this matters beyond disk space
Smaller images aren't just tidier — they have real operational consequences:
- Faster deploys — pulling a 20MB image across a network takes a fraction of the time of pulling an 800MB one, which matters every time you scale up or roll out a new version.
- Smaller attack surface — a compiler, package manager, and shell are all things an attacker could abuse if they compromise a running container. A minimal final image simply doesn't have them to abuse.
- Lower storage costs — at scale, image storage and transfer costs in a registry add up.
A note on caching within multi-stage builds
Layer caching (from the previous lesson) still applies per-stage. In the Go example,COPY go.mod go.sum ./ followed by RUN go mod download happens before COPY . . for exactly the same caching reason as npm install in the previous lesson — dependency downloads get cached and skipped unless the dependency files themselves change.
What's next
We've been running one container at a time, manually wiring up ports. Real applications are usually more than one container — an app plus a database, at minimum. The next module covers how containers talk to each other over the network, and how to persist data so it survives a container being removed.