If your Docker images are bloated with build tools, compilers, and dependencies your app doesn’t need at runtime, you’re wasting bandwidth, slowing deployments, and increasing your attack surface. Docker multi-stage builds solve this elegantly — letting you use one stage to build and another to run, producing minimal production images. In this guide, we’ll walk through practical multi-stage patterns that can cut your image size by 90% or more.
Why Image Size Matters More Than Ever
In 2026, with edge deployments, serverless containers, and auto-scaling Kubernetes clusters, every megabyte counts. Smaller images mean:
- Faster cold starts — critical for serverless and scale-to-zero workloads
- Lower bandwidth costs — especially across multi-region registries
- Reduced attack surface — fewer packages means fewer CVEs
- Quicker CI/CD pipelines — less time pushing and pulling layers
The Problem: Single-Stage Bloat
Here’s a typical single-stage Dockerfile for a Go application:
# ❌ Single-stage: ~1.1GB
FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/server
EXPOSE 8080
CMD ["./server"]This image includes the entire Go toolchain, build cache, and source code — none of which are needed at runtime. The result? A 1.1GB image for a binary that’s only 15MB.
The Fix: Multi-Stage Builds
Multi-stage builds let you define multiple FROM instructions. Each one starts a new stage, and you can selectively copy artifacts between them:
# ✅ Multi-stage: ~12MB
# Stage 1: Build
FROM golang:1.23 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: Run
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]We went from 1.1GB to 12MB — a 99% reduction. The scratch base image is literally empty, containing only what we copy into it.
Key Techniques Used
CGO_ENABLED=0— produces a statically linked binary (no glibc dependency)-ldflags='-s -w'— strips debug symbols and DWARF infoCOPY --from=builder— cherry-picks only the compiled binary and TLS certs
Multi-Stage for Node.js Applications
Node.js apps benefit enormously from multi-stage builds by separating dependency installation from the runtime:
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: Build (if using TypeScript/bundler)
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]This three-stage approach ensures devDependencies (TypeScript, testing tools, linters) never make it into production. Typical savings: 800MB → 150MB.
Multi-Stage for Python Applications
Python is trickier because of C extension compilation, but the pattern still works great:
# Stage 1: Build wheels
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y gcc libffi-dev
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Stage 2: Install and run
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && rm -rf /wheels
COPY . .
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]The build stage compiles C extensions with gcc, but the runtime stage doesn’t need the compiler at all. Savings: ~40% smaller and no compiler in production.
Advanced Pattern: Build Cache Optimization
Combine multi-stage builds with Docker’s cache mounts for blazing-fast rebuilds:
# syntax=docker/dockerfile:1
FROM rust:1.82 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release && cp target/release/myapp /myapp
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /myapp /
ENTRYPOINT ["/myapp"]The --mount=type=cache directive preserves the cargo registry and build artifacts between builds, making incremental compilation nearly instant while still producing a tiny runtime image.
Choosing Your Runtime Base Image
Your choice of final stage base image has a huge impact:
scratch— 0MB, for static binaries only (Go, Rust)distroless— ~2-20MB, includes libc but no shell or package manageralpine— ~7MB, includes musl libc, shell, and apk*-slim— ~50-80MB, Debian-based with minimal packages
Rule of thumb: use the smallest base that your application can run on. If you need a shell for debugging, use Alpine. If you don’t, go distroless or scratch.
Debugging Multi-Stage Builds
You can build and stop at any stage using --target:
# Build only the builder stage
docker build --target builder -t myapp:debug .
# Shell into it to inspect
docker run -it myapp:debug /bin/shThis is invaluable for debugging build failures without waiting for the full pipeline.
Security Scanning: Before and After
Run a quick scan to see the CVE difference:
# Scan the bloated image
docker scout cves myapp:single-stage
# Result: 142 vulnerabilities (23 critical)
# Scan the multi-stage image
docker scout cves myapp:multi-stage
# Result: 0 vulnerabilitiesFewer packages literally means fewer things that can be exploited. A scratch-based image with a static binary has zero OS-level CVEs by definition.
Quick Checklist
- ✅ Separate build and runtime stages
- ✅ Use
--omit=devor equivalent to exclude dev dependencies - ✅ Choose the smallest viable base image for your final stage
- ✅ Copy only the artifacts you need with
COPY --from= - ✅ Strip binaries where possible (
-ldflags='-s -w',strip) - ✅ Use cache mounts for faster rebuilds
- ✅ Scan your final image for CVEs
Conclusion
Docker multi-stage builds are one of the highest-impact, lowest-effort optimizations you can make to your containerized applications. Whether you’re shipping Go microservices, Node.js APIs, or Python ML models, the pattern is the same: build heavy, ship light. Start applying these patterns today — your CI pipeline, cloud bill, and security team will thank you.

Leave a Reply