/lessons/2026-04-01
Docker Multi-Stage Builds: Production-Grade Container Optimization
Master Docker multi-stage builds to create production containers that are 90% smaller, more secure, and faster to deploy by separating build environments from runtime environments.
Docker Multi-Stage Builds: Production-Grade Container Optimization
A typical Node.js Docker image built the naive way can be over 1 GB. The same application built with multi-stage builds can be under 100 MB. This isn't just about saving disk space—it's about fundamentally rethinking how we build production containers.
Multi-stage builds are useful to anyone who has struggled to optimize Dockerfiles while keeping them easy to read and maintain. With multi-stage builds, you use multiple FROM statements in your Dockerfile. Each FROM instruction can use a different base, and each of them begins a new stage of the build. You can selectively copy artifacts from one stage to another, leaving behind everything you don't want in the final image.
The Problem: Build vs Runtime Requirements
Building and running apps are two completely separate problems with different sets of requirements and constraints. So, the build and runtime images should also be completely separate! Nevertheless, the need for such a separation is often overlooked, and production images end up having linters, compilers, and other dev tools in them.
Consider a Go application. Your build environment needs the full Go compiler toolchain, possibly CGO dependencies, build tools like make, and development utilities. Your production environment needs exactly one thing: the compiled binary. Yet traditional single-stage builds bundle everything together.
The golang:1.23 brings more than 800MB of packages and about the same number of CVEs—none of which your production application actually needs.
Multi-Stage Architecture
Conceptually, think of this as a relay race. The first stage (the builder) runs the heavy lifting, grabs the baton (the compiled artifact), and passes it to the second stage (the runner). The first stage—and all its heavy layers—is then discarded, leaving only the lean final image.
The magic of multi-stage builds lies in two specific syntax capabilities: aliasing build stages and copying artifacts between them. Instead of a standard FROM image, we use FROM image AS alias. In the final stage, we use COPY --from=alias to selectively pull files from the previous stage's filesystem into the new one.
Language-Specific Patterns
Go: From Gigabytes to Megabytes
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Copy dependency files first for better caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
# Production stage
FROM scratch
COPY --from=builder /app/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["/app"]
Go application: Single stage (golang:1.22): ~1.1GB, Multi-stage (golang -> scratch): ~8MB (99% reduction). The scratch base image is literally empty—zero bytes—perfect for static binaries.
Node.js: Eliminating devDependencies
# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy only production dependencies
COPY --from=deps /app/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Node.js application: Single stage (node:20): ~1.2GB, Multi-stage (node -> node:alpine): ~150MB (87% reduction).
Advanced Caching Strategies
While multi-stage builds reduce size, how you structure your instructions determines build speed. Docker caches layers based on the instructions used to create them. If an instruction changes (or the files it copies change), Docker invalidates that layer and every layer following it.
The critical insight: A common mistake is copying all source code before installing dependencies. It consequently invalidates the cache for RUN npm install, forcing a full re-installation of your dependencies. This adds minutes to your CI/CD pipeline unnecessarily.
Anti-pattern:
COPY . . # Changes every commit
RUN npm install # Cache invalidated every time
Optimized pattern:
COPY package*.json ./ # Changes only when dependencies change
RUN npm ci # Cached until dependencies change
COPY . . # Source changes don't affect dependency cache
RUN npm run build
BuildKit Optimizations
BuildKit only builds the stages that the target stage depends on. There is no dependency on stage1, so it's skipped. This means unused stages don't consume build time—critical for complex Dockerfiles with multiple build variants.
$ DOCKER_BUILDKIT=1 docker build --no-cache -f Dockerfile --target stage2 .
Use --target to build specific stages during development, dramatically reducing iteration time.
Security Implications
Smaller images mean faster pulls, less storage cost, reduced attack surface, and quicker container startup. They separate build-time concerns from runtime concerns, producing images that are smaller, faster to deploy, and more secure.
Every package, binary, and library in your image represents potential attack surface. Multi-stage builds let you include only what's absolutely necessary for runtime, eliminating entire categories of vulnerabilities.
Pro Tip
You should also consider using two types of base image: one for building and unit testing, and another (typically slimmer) image for production. In the later stages of development, your image may not require build tools such as compilers, build systems, and debugging tools. A small image with minimal dependencies can considerably lower the attack surface.
For ultimate optimization, use distroless images for your production stage. Google's distroless images contain only your application and runtime dependencies—no shell, no package manager, no unnecessary binaries:
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app/dist ./
EXPOSE 3000
CMD ["index.js"]
Example
Here's a production-ready multi-stage build for a TypeScript API:
# syntax=docker/dockerfile:1
# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm run test
# Production stage
FROM gcr.io/distroless/nodejs20-debian12 AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["dist/index.js"]
This pattern ensures your production image contains zero build tools, zero source code, and zero development dependencies—just the compiled application and its runtime requirements.