Docker multi-stage builds are the difference between 1GB images that take minutes to deploy and 100MB images that deploy in seconds. They let you use one image for building and a minimal image for production — automatically, in a single Dockerfile.
:::note[TL;DR]
- Multi-stage builds use multiple
FROMstatements in one Dockerfile - Build in a full image (Node, Python), ship in a minimal image (Alpine, distroless)
- Copy only compiled artifacts between stages with
COPY --from=builder - Typical size reduction: 500MB → 50MB (90% smaller)
- Use
.dockerignoreto exclude files from build context :::
The Problem: Bloated Production Images
Before Multi-Stage
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Result: 1.2GB image containing:
- Full Node.js runtime
- 800MB of
node_modules(dev dependencies included) - Source code and TypeScript files
- Build tools (webpack, babel, typescript)
- Git history and test files
All of this gets deployed to production, slowing down pulls, wasting bandwidth, and increasing attack surface.
After Multi-Stage
# Build stage
FROM node:20 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
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
Result: 120MB image containing only:
- Minimal Node.js runtime (Alpine)
- Compiled JavaScript (no source)
- Production
node_modulesonly - No build tools
How Multi-Stage Builds Work
┌─────────────────┐ ┌─────────────────┐
│ Build Stage │ │ Production │
│ (Full Image) │ ──────► │ Stage │
│ │ COPY │ (Minimal Image) │
│ • Build tools │ │ │
│ • Source code │ │ • Compiled app │
│ • Dev deps │ │ • Runtime deps │
│ • Git history │ │ • No build tools │
└─────────────────┘ └─────────────────┘
Dropped Deployed
Each FROM starts a new stage. Earlier stages are discarded except for files explicitly copied with COPY --from=stage_name.
Real-World Examples
Node.js Application
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy production files
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
COPY package.json ./
# Set proper permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV NODE_ENV production
CMD ["node", "dist/server.js"]
Python Application
# Stage 1: Builder
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Production
FROM python:3.11-alpine
WORKDIR /app
# Copy only installed packages
COPY --from=builder /root/.local /root/.local
# Copy application code
COPY src ./src
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Go Application (Ultimate Size Reduction)
# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Production (Scratch = empty image)
FROM scratch
# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /app/main .
# Copy timezone data (optional)
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
EXPOSE 8080
# Run as non-root (even in scratch)
USER 65534:65534
CMD ["./main"]
Result: 15MB image (vs 800MB with full Go image)
React/Vite Frontend
# 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 (Nginx)
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Rust Application
# Stage 1: Build
FROM rust:1.75-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
# Stage 2: Production
FROM debian:bookworm-slim
# Install runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy binary
COPY --from=builder /app/target/release/myapp /usr/local/bin/
# Create non-root user
RUN useradd -m -u 1000 appuser
USER appuser
EXPOSE 8080
CMD ["myapp"]
Java/Spring Boot
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY src ./src
RUN ./gradlew bootJar --no-daemon
# Stage 2: Production
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
# Copy JAR
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Advanced Patterns
Caching Dependencies Separately
# Stage 1: Download dependencies (cached layer)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build (reuses deps layer if package.json unchanged)
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
Build Arguments for Flexibility
# Build with different Node versions
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder
# ... rest of build
FROM node:${NODE_VERSION}-alpine AS production
# ... production stage
Build with different versions:
docker build --build-arg NODE_VERSION=18 -t myapp:node18 .
docker build --build-arg NODE_VERSION=20 -t myapp:node20 .
Selective Stage Building
Build specific stages only:
# Build just the test stage
docker build --target tester -t myapp:test .
# Build just production
docker build --target production -t myapp:latest .
Dockerfile with test stage:
FROM node:20-alpine AS deps
# ... install dependencies
FROM node:20-alpine AS tester
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm test
FROM node:20-alpine AS builder
# ... build
FROM node:20-alpine AS production
# ... production
Security Scanning Stage
# Production stage
FROM node:20-alpine AS production
# ... production setup
# Security scanning stage (optional, doesn't affect production)
FROM aquasec/trivy:latest AS scanner
COPY --from=production /app /scan
RUN trivy filesystem --exit-code 0 --no-progress /scan
Optimization Strategies
1. Layer Caching
Order Dockerfile commands by change frequency (least → most):
# 1. System dependencies (rarely change)
FROM node:20-alpine
RUN apk add --no-cache curl
# 2. Application dependencies (change occasionally)
COPY package*.json ./
RUN npm ci --only=production
# 3. Application code (changes frequently)
COPY . .
RUN npm run build
2. .dockerignore
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.local
.env.production
.nyc_output
coverage
.nyc_output
.vscode
.idea
*.md
dist
build
.DS_Store
3. Minimal Base Images
| Language | Full Image | Minimal Image | Size |
|---|---|---|---|
| Node.js | node:20 | node:20-alpine | 1GB → 150MB |
| Python | python:3.11 | python:3.11-alpine | 900MB → 50MB |
| Go | golang:1.21 | scratch + binary | 800MB → 15MB |
| Java | eclipse-temurin:21-jdk | eclipse-temurin:21-jre-alpine | 600MB → 200MB |
4. Compress Images Further
# Use upx for binary compression (Go, Rust)
FROM golang:1.21-alpine AS builder
# ... build
RUN apk add --no-cache upx
RUN upx --best --lzma main
FROM scratch
COPY --from=builder /app/main .
Size Comparison
| Application | Single-Stage | Multi-Stage | Reduction |
|---|---|---|---|
| Node.js API | 1.2 GB | 120 MB | 90% |
| Python Django | 980 MB | 180 MB | 82% |
| Go Microservice | 850 MB | 15 MB | 98% |
| React Frontend | 1.5 GB | 25 MB | 98% |
| Java Spring | 720 MB | 210 MB | 71% |
Security Benefits
Smaller Attack Surface
# Bad: Full Ubuntu with unnecessary packages
FROM ubuntu:22.04
RUN apt-get install -y python3 nodejs gcc make curl wget vim
# Good: Alpine with only runtime dependencies
FROM python:3.11-alpine
RUN pip install --no-cache-dir -r requirements.txt
No Build Tools in Production
# Build stage has compilers
FROM gcc:latest AS builder
RUN gcc -o app source.c
# Production stage has no compilers
FROM scratch
COPY --from=builder /app/app .
Non-Root User
FROM node:20-alpine
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Switch to non-root
USER nextjs
Debugging Multi-Stage Builds
Inspect Intermediate Stages
# Build and inspect builder stage
docker build --target builder -t myapp:builder .
docker run -it myapp:builder sh
# Check what's in production image
docker build --target production -t myapp:prod .
docker run -it myapp:prod sh
Dive Tool
# Install dive
wget https://github.com/wagoodman/dive/releases/download/v0.10.0/dive_0.10.0_linux_amd64.deb
sudo dpkg -i dive_0.10.0_linux_amd64.deb
# Analyze image layers
dive myapp:latest
CI/CD Integration
GitHub Actions
name: Build and Push
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:latest
target: production
cache-from: type=gha
cache-to: type=gha,mode=max
GitLab CI
build:
image: docker:latest
services:
- docker:dind
script:
- docker build --target production -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:latest
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- /root/.docker
Common Pitfalls
1. Forgetting to Copy All Dependencies
# Wrong: Missing some node_modules
COPY --from=builder /app/node_modules ./node_modules
# Right: Copy all or use production-only install
COPY --from=deps /app/node_modules ./node_modules
2. Using Wrong Stage Names
# Wrong: Typo in stage name
COPY --from=bulder /app/dist ./dist # builder misspelled
# Right: Match stage name exactly
COPY --from=builder /app/dist ./dist
3. Building on Wrong Architecture
# For multi-arch builds, use BuildKit
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
# ...
FROM node:20-alpine
COPY --from=builder /app/dist ./dist
4. Caching Issues with COPY
# Wrong: COPY invalidates cache too early
COPY . .
RUN npm ci # Runs on every code change
# Right: Copy package.json first
COPY package*.json ./
RUN npm ci # Cached unless package.json changes
COPY . .
Summary
- Multi-stage builds = Multiple
FROMstatements, one Dockerfile - Copy selectively — Use
COPY --from=stage_nameto move only needed files - Minimal final image — Use Alpine, distroless, or scratch
- Cache smartly — Order by change frequency, use
.dockerignore - Security built-in — Smaller images, no build tools, non-root users
90% smaller images aren’t just nice to have — they’re faster to pull, cheaper to store, and more secure to run.
What to Read Next
- Docker Cheat Sheet — Complete Docker reference
- Dockerfile Best Practices — Optimization guide
- Kubernetes for Developers — Deploy these images