Docker Multi-Stage Builds 2025 — Cut Image Sizes by 80% with Examples

Sanjeev SharmaSanjeev Sharma
7 min read

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 --from references 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 --from references
  • Put the most stable, least-changed steps first in each stage to maximize cache hits
  • Use --mount=type=cache for package manager caches — eliminates redundant downloads without polluting image layers
  • Prefer FROM scratch or distroless for compiled languages — the production image contains only your binary
  • Use --target stagename in CI to build test and lint stages before the production stage

Key Takeaways

  • Multi-stage builds use multiple FROM instructions in one Dockerfile; each stage uses COPY --from=stagename to 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 stagename builds 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 scratch images with no OS dependencies whatsoever
  • Multi-stage patterns are the production standard — single-stage Dockerfiles for non-trivial apps are a code smell

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading