Docker Multi-Stage Builds: Advanced Optimization Techniques
Go beyond basic multi-stage builds. Learn cache mounts, parallel stages, and distroless bases to shrink Docker images by 90%.
What you'll learn
- ✓How multi-stage builds separate build-time and runtime concerns
- ✓Advanced patterns like parallel stages and cache mounts
- ✓Using distroless and scratch bases for minimal images
Prerequisites
- •Basic Docker knowledge — see Docker First Container
- •Familiarity with writing Dockerfiles
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each FROM starts a new stage, and you copy only the artifacts you need into the final image. The result is a production image that contains your binary or bundle and nothing else — no compilers, no dev dependencies, no package manager caches.
This article goes beyond the basics and covers optimization patterns that cut image sizes dramatically and speed up CI pipelines.
The Problem with Single-Stage Builds
A typical single-stage Node.js Dockerfile looks like this:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
This image includes the full Node.js runtime, npm, build tools, source files, and node_modules with dev dependencies. A simple Express app built this way can easily be 1.2 GB.
Basic Multi-Stage Pattern
Split the build and runtime into separate stages:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]
This is better, but we still ship all of node_modules including dev dependencies. Let us improve that.
Optimized Multi-Stage with Pruned Dependencies
Add a dedicated stage for production dependencies:
# Stage 1: Install all deps and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production deps only
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# Stage 3: Final image
FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
By separating the dependency install into its own stage, Docker can cache the production node_modules independently from the build stage. The final image drops from 1.2 GB to around 180 MB.
Parallel Stages with BuildKit
Docker BuildKit runs independent stages in parallel. If your stages do not depend on each other, BuildKit builds them concurrently:
# These two stages run in parallel
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM golang:1.22-alpine AS backend-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
# Final stage pulls from both
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=backend-builder /app/server /usr/local/bin/server
COPY --from=frontend-builder /app/frontend/dist /var/www/static
EXPOSE 8080
CMD ["server"]
Enable BuildKit with DOCKER_BUILDKIT=1 or set it as default in your Docker daemon config. Both builder stages execute simultaneously, cutting total build time significantly.
Cache Mounts for Package Managers
BuildKit cache mounts persist package manager caches between builds. Instead of downloading everything from scratch each time:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o server .
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The --mount=type=cache directive keeps the Go module cache and build cache across builds. Subsequent builds that share the same dependencies skip the download entirely.
For Node.js, cache the npm cache directory:
RUN --mount=type=cache,target=/root/.npm npm ci
Using Distroless and Scratch Bases
For compiled languages, the final image does not need a full OS:
Scratch — absolutely nothing, just your binary:
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
The scratch image has no shell, no package manager, no libc. Your binary must be statically compiled. Image size is just your binary — often under 15 MB.
Distroless — Google’s minimal images that include just enough runtime:
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Distroless images provide CA certificates and timezone data without a shell or package manager. They sit between scratch and alpine in size, typically around 2-5 MB base.
Build Arguments for Conditional Stages
Use ARG and --target to build different variants from the same Dockerfile:
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
FROM base AS development
CMD ["npm", "run", "dev"]
FROM base AS builder
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]
Build specific targets:
# Development image with hot reload
docker build --target development -t myapp:dev .
# Production image
docker build --target production -t myapp:prod .
Size Comparison
Here is a real comparison for a Go REST API:
| Approach | Image Size |
|---|---|
Single stage with golang:1.22 | 1.1 GB |
Multi-stage with alpine | 25 MB |
Multi-stage with distroless | 8 MB |
Multi-stage with scratch | 6 MB |
Practical Tips
Order COPY statements carefully. Copy files that change less frequently first. package.json before source code, go.mod before .go files. This maximizes layer cache hits.
Use .dockerignore. Exclude node_modules, .git, test files, and documentation from the build context. A smaller context means faster builds.
node_modules
.git
*.md
test/
coverage/
Pin base image digests in production. Tags like alpine:3.20 can change. For reproducible builds, pin to a digest:
FROM node:20-alpine@sha256:abc123... AS builder
Strip binaries. For Go, use -ldflags="-s -w" to remove debug symbols. This typically saves 30-40% of binary size.
Summary
Multi-stage builds are the single most impactful optimization for Docker images. The key techniques are: separate build and runtime stages, use parallel stages with BuildKit, leverage cache mounts for package managers, and choose the smallest possible base image for your final stage. A well-optimized multi-stage build can reduce image sizes by 90% or more while making builds faster and more secure.
Related articles
- Docker Building Slim Docker Images: From 1GB to 50MB
Practical techniques to reduce Docker image size by 90% or more. Covers multi-stage builds, Alpine and distroless bases, layer optimization, and dependency management.
- Docker Docker Multi-Stage Builds for Smaller Images
Use Docker multi-stage builds to ship tiny production images — build with full toolchains, copy only the artifacts. Examples for Node and Go, with .dockerignore and size comparisons.
- Docker Docker BuildKit Advanced Features You Should Be Using
Master BuildKit's cache mounts, secret mounts, SSH forwarding, and parallel builds to speed up Docker image creation and keep secrets out of layers.
- Docker Docker Compose Patterns for Production
Production-ready Docker Compose patterns: override files, healthchecks, resource limits, logging, and secrets management.