If your Docker images are bloated and slow to deploy, multi-stage builds are the single most impactful optimization you can make. By separating your build environment from your runtime environment, you can slash image sizes from gigabytes to megabytes — often achieving a 90% reduction. In this guide, we will walk through practical multi-stage build patterns for real-world applications.
Why Image Size Matters
Large Docker images cause real problems in production:
- Slower deployments — pulling a 2GB image across a network adds minutes to every deploy
- Higher costs — registry storage and bandwidth add up fast at scale
- Larger attack surface — every unnecessary package is a potential vulnerability
- Slower CI/CD — build pipelines bottleneck on image push/pull
Multi-stage builds solve all of these by letting you use full-featured build tools during compilation, then copy only the final artifacts into a minimal runtime image.
The Basic Pattern
A multi-stage Dockerfile uses multiple FROM statements. Each one starts a new stage, and you can copy files between stages:
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:22-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/index.js"]The builder stage has all your dev dependencies and source code. The production stage only gets the compiled output. Everything else is discarded.
Real-World Example: Go Application
Go is where multi-stage builds really shine, because Go compiles to a static binary that needs zero runtime dependencies:
# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server
# Stage 2: Minimal runtime
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]Notice we use FROM scratch — the empty base image. The final image contains literally just your binary and SSL certificates. A typical result:
- Build image: ~1.1 GB (golang:1.23-alpine + dependencies)
- Final image: ~12 MB (just the binary)
That is a 99% reduction.
Python Multi-Stage Pattern
Python is trickier because it is interpreted, but multi-stage builds still help enormously by separating build-time compilation from runtime:
# Stage 1: Build dependencies
FROM python:3.13-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
# Stage 2: Runtime
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "-b", "0.0.0.0:8000"]Key trick: --prefix=/install puts all pip packages in a clean directory you can copy wholesale. The runtime image never needs gcc or other build tools.
Advanced: Three-Stage Build with Testing
You can add a test stage that runs in CI but does not affect your production image:
# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Test (runs in CI)
FROM deps AS test
COPY . .
RUN npm run lint && npm run test
# Stage 3: Build for production
FROM deps AS builder
COPY . .
RUN npm run build && npm prune --production
# Stage 4: Production
FROM node:22-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]Build just the production image: docker build --target production .
Run tests in CI: docker build --target test .
Pro Tips for Maximum Optimization
1. Use Alpine or Distroless Base Images
Alpine Linux images are ~5MB versus ~130MB for Debian-based images. For even smaller images, Google Distroless provides minimal images with just a language runtime:
# Ultra-minimal Java runtime
FROM gcr.io/distroless/java21-debian12
COPY --from=builder /app/target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]2. Order Layers for Cache Efficiency
Always copy dependency files before source code. This way, npm ci or pip install is cached unless dependencies actually change:
COPY package*.json ./ # Changes rarely
RUN npm ci # Cached when package.json unchanged
COPY . . # Changes often — but previous layers cached3. Use .dockerignore
Keep build context small to speed up builds:
node_modules
.git
*.md
.env
dist
coverage
.github4. Leverage BuildKit Cache Mounts
Docker BuildKit (enabled by default in 2026) supports cache mounts that persist across builds:
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txtThis keeps pip/npm caches between builds without bloating the final image.
Measuring Your Results
Always verify your optimization worked:
# Compare image sizes
docker images | grep myapp
# Inspect layers
docker history myapp:latest
# Deep dive with dive tool
dive myapp:latestThe dive tool is especially useful — it shows you exactly what each layer contributes and highlights wasted space.
Conclusion
Multi-stage builds are a must-have technique for any containerized application. Start with the basic two-stage pattern, then layer on optimizations like distroless bases, cache mounts, and targeted build stages. The payoff is immediate: smaller images, faster deploys, and a tighter security posture. If you have not revisited your Dockerfiles recently, now is the time.

Leave a Reply