Docker Multi-Stage Builds 2025 — Cut Image Sizes by 80% with Examples
Advertisement
Introduction
Why This Matters
A Node.js application that uses TypeScript starts as ~1.1 GB if you install all dependencies and build tools in a single image. With multi-stage builds, the same application ships in ~80–120 MB — a reduction of nearly 90%. That difference compounds: faster CI pulls, cheaper registry storage, faster Kubernetes pod startups, and a dramatically smaller attack surface.
Multi-stage builds, introduced in Docker 17.05, allow a single Dockerfile to define multiple build stages. Each stage can use a different base image, and you selectively copy artifacts from one stage to the next. The final image contains only what you explicitly copy — no compilers, no devDependencies, no test frameworks.
In 2025 with BuildKit enabled by default, multi-stage builds also support parallel stage execution and cache mounts, making them even more powerful for large monorepos and polyglot services.
How Multi-Stage Builds Work
# Stage 1: builder — installs everything needed to compile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # includes devDependencies
COPY . .
RUN npm run build # compiles TypeScript to dist/
# Stage 2: production — only what is needed to run
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production # no devDependencies
COPY --from=builder /app/dist ./dist # compiled output from stage 1
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]The --from=builder instruction copies specific files from a named stage. The builder stage is used during build time and discarded from the final image.
Node.js / TypeScript
# Stage 1: Install and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build && npm run test
# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -q --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]Size comparison:
- Single-stage: ~1.1 GB
- Multi-stage: ~120 MB
Go — The Extreme Case
Go compiles to a single static binary, enabling the smallest possible production images:
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Download modules separately for cache
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0: static binary, no libc dependency
# -ldflags="-s -w": strip debug symbols and DWARF info
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o server ./cmd/server
# Stage 2: Scratch (empty image — just the binary)
FROM scratch AS production
# Copy TLS certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the binary
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]Size comparison:
- Builder stage: ~300 MB (Go compiler + source)
- Final scratch image: ~8–15 MB
Python
# Stage 1: Build virtual environment
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Minimal production image
FROM python:3.12-slim AS production
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application source
COPY . .
# Non-root user
RUN useradd -r -s /sbin/nologin appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4"]Java / Spring Boot
Spring Boot creates a fat JAR that can be layered for better Docker cache efficiency:
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
# Download dependencies separately for caching
RUN ./mvnw dependency:go-offline -q
COPY src ./src
RUN ./mvnw package -DskipTests
# Extract JAR layers for Docker layer caching
RUN java -Djarmode=layertools -jar target/*.jar extract
# Stage 2: Production (JRE, not JDK)
FROM eclipse-temurin:21-jre-alpine AS production
WORKDIR /app
# Spring Boot layers from largest (least changed) to smallest (most changed)
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
RUN adduser -D -s /sbin/nologin appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]Rust
# Stage 1: Build with full toolchain
FROM rust:1.77-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev
COPY Cargo.toml Cargo.lock ./
# Cache dependency compilation separately
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -f src/main.rs
COPY src ./src
RUN touch src/main.rs && cargo build --release
# Stage 2: Minimal final image
FROM alpine:3.19 AS production
WORKDIR /app
COPY --from=builder /app/target/release/myapp ./myapp
RUN adduser -D -s /sbin/nologin appuser
USER appuser
EXPOSE 8080
CMD ["./myapp"]Parallel Stages with BuildKit
BuildKit can execute independent stages in parallel, speeding up complex builds:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS test
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm test # runs in parallel with builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # runs in parallel with test
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist # depends on builder
USER node
CMD ["node", "dist/server.js"]# Enable BuildKit and build (test and builder run in parallel)
DOCKER_BUILDKIT=1 docker build -t myapp:1.0 .
# Or use docker buildx build (BuildKit enabled by default)
docker buildx build -t myapp:1.0 .Targeting Specific Stages
Build only a specific stage — useful for running tests in CI without building the production image:
# Build only the test stage
docker build --target test -t myapp:test .
docker run --rm myapp:test
# Build only the development stage
docker build --target development -t myapp:dev .
# Build the production stage (default: last stage)
docker build -t myapp:prod .Cache Mounts with Multi-Stage (BuildKit)
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Cache mount: npm cache persists on the build host across builds
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Common Mistakes
- Copying the entire source directory before running dependency installation — invalidates dependency cache on every code change
- Forgetting to copy TLS certificates into scratch images — HTTPS calls fail with x509 errors
- Building multiple unrelated applications in a single Dockerfile — split into separate Dockerfiles for clarity and independent caching
- Not naming stages (
AS builder) — makes--fromreferences brittle if you add stages - Rerunning expensive build steps (TypeScript compilation, Maven) when only tests change
Best Practices
- Always name your stages with
AS stagename— enables targeted builds and readable--fromreferences - Put the most stable, least-changed steps first in each stage to maximize cache hits
- Use
--mount=type=cachefor package manager caches — eliminates redundant downloads without polluting image layers - Prefer
FROM scratchor distroless for compiled languages — the production image contains only your binary - Use
--target stagenamein CI to build test and lint stages before the production stage
Key Takeaways
- Multi-stage builds use multiple
FROMinstructions in one Dockerfile; each stage usesCOPY --from=stagenameto pull artifacts - A TypeScript Node.js app drops from ~1.1 GB to ~120 MB; a Go service can be as small as 8 MB in a scratch image
- BuildKit executes independent stages in parallel — test and build stages can run simultaneously in CI
--target stagenamebuilds only a specific stage — useful for running tests in CI without a full production build- Cache mounts (
--mount=type=cache) persist package manager caches on the build host without embedding them in image layers - Separate dependency installation from source copying in every stage to maximize Docker layer cache effectiveness
- Go and Rust produce static binaries that run in
FROM scratchimages with no OS dependencies whatsoever - Multi-stage patterns are the production standard — single-stage Dockerfiles for non-trivial apps are a code smell
Advertisement