The Problem
Your Docker image is 1.2GB. It contains build tools, source code, dev dependencies, and compilation artifacts that have no business being in production. Large images mean slow deployments, increased storage costs, larger attack surface, and longer pod startup times in Kubernetes.
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Build in one stage, copy only the artifacts you need to a minimal final stage. The result: images that are 10-50x smaller.
How Multi-Stage Builds Work
A multi-stage Dockerfile has multiple FROM instructions. Each FROM starts a new build stage. You can copy files between stages with COPY --from=stage_name:
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production (only contains built output)
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
The final image only contains what was explicitly copied from the builder stage.
Node.js Multi-Stage Build
Before (single stage) — ~1.1GB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
After (multi-stage) — ~180MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production --ignore-scripts && \
cp -R node_modules prod_modules && \
npm ci && \
npm run build
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder /app/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
Key techniques:
- Alpine base image (5MB vs 900MB for full Debian)
- Separate production dependencies from dev dependencies
- Non-root user for security
- Health check built into the image
Go Multi-Stage Build
Go compiles to a static binary — the final image can be scratch (0 bytes base):
Production Go Dockerfile — ~12MB final image
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
If you need shell access for debugging, use distroless instead of scratch:
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Java Multi-Stage Build
Spring Boot — from 800MB to 200MB
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw && ./mvnw dependency:resolve
COPY src ./src
RUN ./mvnw package -DskipTests -Dmaven.javadoc.skip=true
RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -g 1001 -S spring && \
adduser -S spring -u 1001 -G spring
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
The layered extraction means Docker can cache dependency layers separately from application code.
Python Multi-Stage Build
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
RUN useradd -r -s /sbin/nologin appuser
COPY . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000", "--workers", "4"]
Build Cache Optimization
Layer ordering dramatically affects build speed:
FROM node:20-alpine AS builder
WORKDIR /app
# Layer 1: Package files change rarely — cached most of the time
COPY package.json package-lock.json ./
RUN npm ci
# Layer 2: Source code changes frequently — only this rebuilds
COPY src/ ./src/
COPY tsconfig.json ./
RUN npm run build
Using BuildKit cache mounts
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
# Enable BuildKit
DOCKER_BUILDKIT=1 docker build -t myapp .
Cache mounts for Go
# syntax=docker/dockerfile:1
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 -o /server ./cmd/server
Security Hardening
Use distroless images
Distroless images contain only your application and its runtime dependencies — no shell, no package manager:
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
CMD ["dist/server.js"]
Scan for vulnerabilities
docker scout cves myapp:latest
trivy image myapp:latest
trivy image --severity CRITICAL,HIGH myapp:latest
Measuring Image Size
# Check image size
docker images myapp
# Analyze layers
docker history myapp:latest
# Detailed analysis with dive
dive myapp:latest
# Compare before and after
docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep myapp
Common Mistakes
node_modules to .dockerignore. Installing inside the container ensures consistent, platform-appropriate binaries..dockerignore — Without it, COPY . . sends .git, node_modules, test files, and documentation to the build context.npm ci --only=production or separate the dependency install step.FROM node:latest can break builds when a new version releases. Use FROM node:20.11-alpine.USER directive in the final stage.COPY package*.json before COPY . . so dependency installation is cached when only source code changes.Quick Reference
| Technique | Image Size Impact |
|---|---|
| Alpine base | -700MB (vs Debian) |
| Multi-stage build | -500MB to -1GB |
| Production deps only | -100MB to -300MB |
| Distroless base | Additional -50MB |
| <code class="inline-code">--ldflags="-w -s"</code> (Go) | -30% binary size |
| Scratch base (Go) | Final image = binary size only |
| <code class="inline-code">.dockerignore</code> | Faster builds, smaller context |
| BuildKit cache mounts | 2-5x faster rebuilds |
Summary
Multi-stage builds are the single most impactful optimization for Docker images. Separate your build environment from your runtime environment. Use Alpine or distroless base images for the final stage. Order your layers for maximum cache efficiency. The result is smaller images that deploy faster, use less storage, and have a minimal attack surface.
---
Frequently Asked Questions
What is a Docker multi-stage build?
A multi-stage build uses multiple FROM statements in a single Dockerfile, allowing you to use one stage for building/compiling and another for the final runtime image. Only the final stage is included in the produced image, dramatically reducing size by excluding build tools, source code, and intermediate artifacts.
How does COPY --from work in multi-stage builds?
COPY --from=builder /app/dist ./dist copies files from a named build stage (defined with FROM node:18 AS builder) into the current stage. You can also copy from external images using COPY --from=nginx:alpine /etc/nginx/nginx.conf ./. This lets you cherry-pick only the artifacts needed for runtime.
How much can multi-stage builds reduce image size?
Multi-stage builds typically reduce image sizes by 50-95%. A Node.js application might go from 1.2GB (with node_modules and build tools) to 100-200MB (with just production dependencies). A Go application can shrink from 800MB to 10-20MB by copying only the compiled binary to a scratch or distroless base image.
When should I use multi-stage builds versus separate Dockerfiles?
Always prefer multi-stage builds over separate Dockerfiles. They keep your build pipeline in a single file, ensure reproducibility, and work with any CI system without requiring intermediate image management. Separate Dockerfiles are only warranted when build stages need different build contexts or when you're sharing intermediate images across multiple final images.
What is the best base image for the final stage?
Use Alpine (5MB) for a minimal Linux environment with a package manager, or Google's distroless images for maximum security with no shell or package manager. For Go applications, scratch (0MB) works since Go compiles to static binaries. For Node.js or Python, slim variants offer a good balance between size and compatibility.
---
Related Resources
- YAML/JSON Converter — YAML to JSON converter for Docker Compose files
- Kubernetes Pod Troubleshooting — K8s troubleshooting after containerization
- Docker Production Best Practices — Security and optimization for production containers
- GitHub Actions CI/CD Guide — Automating Docker builds in CI/CD pipelines
- Docker Container Logs Debugging — Debugging containers built with multi-stage