Multi-stage Docker builds separate the build environment from the final runtime image, producing significantly smaller, more secure production images. This guide covers implementing this pattern effectively.
The Problem with Single-Stage Builds
FROM node:20
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
A single-stage build includes your entire build toolchain (compilers, dev dependencies, build tools) in the final image, even though the running application only needs the compiled output — results in unnecessarily large, less secure images carrying tools not needed at runtime.
The Multi-Stage Solution
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
The first stage (builder) has full build tooling; the final stage starts fresh from a minimal base image, copying only the specific build artifacts actually needed at runtime — the build tools themselves never make it into the final image.
Using an Even Smaller Base Image for the Final Stage
FROM node:20-alpine
Alpine-based images are significantly smaller than standard Debian-based images — worth considering for the final runtime stage where minimal size is valuable, though verify your application's dependencies are actually compatible with Alpine's musl libc (occasionally a source of subtle compatibility issues).
Multi-Stage Build for a Compiled Language (Go Example)
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
FROM alpine:latest
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
Particularly dramatic size benefit for compiled languages — the final image needs only the compiled binary, not the entire Go toolchain, resulting in a genuinely minimal final image.
Verifying the Size Improvement
docker images myapp
Compare image size before and after adopting multi-stage builds — the improvement is often dramatic, particularly for compiled languages or applications with heavy build-time dependencies.
Security Benefits Beyond Size
A smaller final image with fewer included tools also has a reduced attack surface — build tools, compilers, and package managers present in a single-stage image are unnecessary capabilities available to an attacker who compromises a running container; multi-stage builds naturally exclude these from the runtime image.
Using Multiple Intermediate Stages
FROM node:20 AS dependencies
COPY package*.json ./
RUN npm install
FROM dependencies AS builder
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
COPY --from=builder /app/dist ./dist
More complex builds can use several named stages, each building on the previous, improving both organization and caching efficiency (see How to Build Docker Images Efficiently in CI (Layer Caching)).
Common Errors
Application fails to start in the final stage despite building successfully — verify you're copying all genuinely necessary runtime files/dependencies from the builder stage; it's easy to forget a required file that was implicitly present in a single-stage build but needs explicit copying in a multi-stage setup.
Continue Reading
- How to Build Docker Images Efficiently in CI (Layer Caching)
- How to Build Custom Docker Images with Dockerfile
- How to Set Up Zero-Downtime Deployments with Docker
Browse more articles in DevOps & CI/CD.