Back to Containers & Runtime Environments Mastery Series

Part 8: Multi-Stage Builds & Image Optimization

May 14, 2026 Wasil Zafar 26 min read

A single-stage Dockerfile that builds and runs your application ships compilers, package managers, and build artifacts into production — bloating images, expanding attack surface, and slowing deployments. Multi-stage builds solve this by separating build environments from runtime, letting you produce minimal, secure images that contain only what your application actually needs to run.

Table of Contents

  1. Why Image Size Matters
  2. The Single-Stage Problem
  3. Multi-Stage Fundamentals
  4. Multi-Stage Patterns
  5. Choosing Base Images
  6. Alpine Linux Deep Dive
  7. Distroless Images
  8. Layer Caching Strategies
  9. Dependency Minimization
  10. Image Size Comparison
  11. dockerignore Best Practices
  12. Exercises
  13. Conclusion & Next Steps

Why Image Size Matters

Container image size isn't vanity — it directly impacts security, speed, cost, and reliability. Every unnecessary binary, library, or file in your image is a potential attack vector, bandwidth cost, and startup delay. Let's quantify the impact.

Security Principle: The smallest image is the most secure image. Fewer packages mean fewer CVEs to patch, fewer tools for an attacker to exploit, and a dramatically smaller blast radius if compromised.

The Quantifiable Impact

Metric Large Image (900 MB) Optimized Image (15 MB) Improvement
Pull time (100 Mbps) 72 seconds 1.2 seconds 60× faster
Registry storage (100 images) 90 GB 1.5 GB 60× cheaper
Cold start (Kubernetes) ~45 seconds ~3 seconds 15× faster
Known CVEs (typical scan) 200-400 0-5 98% fewer
Attack surface binaries ~2,000 executables 1 executable Minimal

In CI/CD pipelines where images are pulled hundreds of times daily, the bandwidth savings alone can represent thousands of dollars monthly. In serverless and edge computing, cold start time directly impacts user experience.

The Problem with Single-Stage Builds

A single-stage Dockerfile installs build tools, compiles code, and ships everything — including the compiler — into the final image. This is the naive approach that most tutorials teach first.

Example: A Go Application

Consider a simple Go HTTP server. Here's the single-stage approach:

# Single-stage build — DO NOT use in production
FROM golang:1.22

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .

EXPOSE 8080
CMD ["./server"]

Let's inspect the result:

# Build the single-stage image
docker build -t myapp:single-stage .

# Check the image size
docker images myapp:single-stage
# REPOSITORY   TAG            SIZE
# myapp        single-stage   843MB

# Count installed packages
docker run --rm myapp:single-stage dpkg --list | wc -l
# 312

# What's actually needed at runtime?
# Just the compiled binary: 12MB
Analysis

The 843 MB Problem

The final image contains: the entire Go toolchain (500+ MB), git, gcc, libc development headers, CA certificates for go mod download, and hundreds of system utilities. Our actual application binary is only 12 MB — meaning 98.6% of the image is waste. An attacker who compromises this container gets access to a full development environment including compilers and network tools.

image bloat security risk slow deploys

Multi-Stage Build Fundamentals

Multi-stage builds use multiple FROM instructions in a single Dockerfile. Each FROM starts a new build stage. You can selectively copy artifacts from one stage into another, leaving behind everything you don't need in the final image.

Multi-Stage Build Flow
flowchart LR
    subgraph Stage1["Build Stage (golang:1.22)"]
        A[Source Code] --> B[go mod download]
        B --> C[go build]
        C --> D[Binary: 12MB]
    end
    subgraph Stage2["Runtime Stage (scratch)"]
        E[Binary: 12MB]
        F[ca-certificates]
    end
    D -->|"COPY --from=build"| E
    style Stage1 fill:#f0f7ff,stroke:#16476A
    style Stage2 fill:#f0fff7,stroke:#3B9797
                            

The COPY --from= Instruction

The key to multi-stage builds is COPY --from=<stage>, which copies files from a previous stage (or even an external image) into the current stage:

# Multi-stage build — production-ready
FROM golang:1.22 AS build

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 .

# --- Runtime stage ---
FROM scratch

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

Let's verify the dramatic improvement:

# Build the multi-stage image
docker build -t myapp:multi-stage .

# Compare sizes
docker images myapp
# REPOSITORY   TAG            SIZE
# myapp        single-stage   843MB
# myapp        multi-stage    12.1MB

# That's a 98.6% reduction!
# The scratch base has ZERO packages — no shell, no tools, nothing

Named Stages

Naming stages with AS makes Dockerfiles readable and allows targeting specific stages during build:

# Build only the test stage (stops after tests pass)
docker build --target test -t myapp:test .

# Build only the production stage (default — last stage)
docker build -t myapp:prod .

# Build a specific named stage
docker build --target debug -t myapp:debug .

Multi-Stage Patterns by Language

Each language ecosystem has distinct build requirements. Here are production-ready patterns for the most common languages.

Go: Build on Alpine, Ship on Scratch

# Go multi-stage: 843MB → 12MB
FROM golang:1.22-alpine AS build

# Install CA certificates for HTTPS calls at runtime
RUN apk add --no-cache ca-certificates

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -trimpath -o /app/server .

# Runtime: scratch = empty filesystem (0 bytes base)
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app/server /server

USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/server"]

Node.js: Build TypeScript, Ship JavaScript

# Node.js multi-stage: 1.1GB → 180MB
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
    cp -R node_modules /prod_modules
RUN npm ci

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Runtime: only production deps + compiled JS
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

COPY --from=deps /prod_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .

USER nextjs
EXPOSE 3000
CMD ["node", "dist/index.js"]

Python: Build Wheels, Ship Slim

# Python multi-stage: 1.2GB → 145MB
FROM python:3.12-slim AS build

WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libffi-dev && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Runtime: slim base + pre-built packages only
FROM python:3.12-slim AS runtime
WORKDIR /app

COPY --from=build /install /usr/local
COPY . .

RUN useradd -r -s /bin/false appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:create_app()"]

Java: Maven Build, JRE Runtime

# Java multi-stage: 800MB → 210MB
FROM maven:3.9-eclipse-temurin-21 AS build

WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn package -DskipTests -B && \
    mv target/*.jar app.jar

# Custom JRE with jlink — only modules your app needs
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app

RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=build /app/app.jar .

USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

Choosing Base Images

The base image is the foundation of your container. Choosing the right one is a tradeoff between size, compatibility, security, and debugging convenience.

Base Image Size Package Manager C Library Shell Best For
ubuntu:24.04 78 MB apt glibc bash Dev environments, complex dependencies
debian:bookworm-slim 52 MB apt glibc bash General production workloads
alpine:3.20 7.8 MB apk musl ash Minimal images, static binaries
scratch 0 B None None None Statically compiled binaries (Go, Rust)
gcr.io/distroless/static 2.5 MB None None None Go, Rust (with CA certs & tzdata)
gcr.io/distroless/base 20 MB None glibc None C/C++, dynamically linked apps
gcr.io/distroless/java21 220 MB None glibc None Java applications
Decision Framework: Start with the smallest viable base. If your binary is statically compiled (Go, Rust), use scratch or distroless/static. If you need glibc, use distroless/base or debian-slim. Only reach for ubuntu when you need broad package availability during development.

Alpine Linux Deep Dive

Alpine Linux is the most popular minimal base image for containers, but it comes with subtle differences that catch developers off-guard. Understanding musl libc and Alpine's package ecosystem prevents production surprises.

musl vs glibc: The Hidden Gotcha

Alpine uses musl libc instead of glibc. Most differences are invisible, but some matter:

Feature glibc (Debian/Ubuntu) musl (Alpine)
DNS resolution Full resolver with nsswitch Simple resolver (no mDNS)
Thread stack size 8 MB default 128 KB default
Locale support Full ICU Minimal (UTF-8 only)
Pre-built wheels (Python) Available on PyPI Must compile from source
Node.js native addons Binary downloads work May need rebuild

Alpine Best Practices

# Alpine best practices
FROM alpine:3.20

# Always use --no-cache to avoid storing the index
RUN apk add --no-cache \
    curl \
    ca-certificates \
    tzdata

# Install build deps in a virtual package, then remove them
RUN apk add --no-cache --virtual .build-deps \
    gcc musl-dev libffi-dev python3-dev && \
    pip install --no-cache-dir cryptography && \
    apk del .build-deps

# Set timezone without keeping tzdata permanently
RUN cp /usr/share/zoneinfo/UTC /etc/localtime && \
    echo "UTC" > /etc/timezone && \
    apk del tzdata

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Warning — Python on Alpine: Python packages with C extensions (numpy, pandas, cryptography) must be compiled from source on Alpine, adding minutes to builds and requiring gcc/musl-dev. For Python apps, python:3.12-slim (Debian-based) is often faster to build and equally small after multi-stage optimization.

Distroless Images (Google)

Google's distroless images take minimalism to its logical extreme: they contain no shell, no package manager, no utilities — only your application and its runtime dependencies. This means an attacker who gains code execution cannot spawn a shell, install tools, or explore the filesystem interactively.

Distroless for Go

# Go + distroless: maximum security
FROM golang:1.22-alpine AS build

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server .

# Distroless static — includes CA certs and tzdata
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=build /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Distroless for Java

# Java + distroless
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B

FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=build /app/target/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]

Debugging Distroless Containers

Since distroless has no shell, debugging requires special techniques:

# Use the :debug tag which includes busybox shell
docker run --rm -it gcr.io/distroless/static-debian12:debug sh

# Or use ephemeral containers in Kubernetes
kubectl debug -it mypod --image=busybox --target=mycontainer

# Copy files out for inspection
docker cp container_id:/app/config.json ./local-config.json

# Use docker exec with a sidecar that shares the PID namespace
docker run --rm -it --pid=container:myapp busybox ps aux

Layer Caching Strategies

Docker builds layers top-to-bottom. When a layer changes, all subsequent layers are invalidated. Strategic instruction ordering maximizes cache hits and minimizes rebuild times.

Layer Cache Invalidation
flowchart TD
    A["FROM node:20-alpine ✅ cached"] --> B["COPY package.json ✅ cached"]
    B --> C["RUN npm ci ✅ cached"]
    C --> D["COPY . . ❌ source changed"]
    D --> E["RUN npm build ❌ invalidated"]
    E --> F["CMD node dist ❌ invalidated"]
    style A fill:#d4edda,stroke:#28a745
    style B fill:#d4edda,stroke:#28a745
    style C fill:#d4edda,stroke:#28a745
    style D fill:#f8d7da,stroke:#dc3545
    style E fill:#f8d7da,stroke:#dc3545
    style F fill:#f8d7da,stroke:#dc3545
                            

The Golden Rule: Dependencies Before Source

# ❌ BAD: Every source change triggers full npm install
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/index.js"]
# ✅ GOOD: npm ci only runs when package.json changes
FROM node:20-alpine
WORKDIR /app

# Layer 1: Copy dependency manifests (rarely changes)
COPY package.json package-lock.json ./

# Layer 2: Install dependencies (cached unless manifests change)
RUN npm ci

# Layer 3: Copy source code (changes frequently)
COPY . .

# Layer 4: Build (always runs after source changes — that's OK)
RUN npm run build
CMD ["node", "dist/index.js"]

Language-Specific Caching Patterns

# Go: go.mod/go.sum before source
COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Python: requirements.txt before source
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Java: pom.xml before source
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src

# Rust: Cargo.toml/Cargo.lock before source
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main(){}" > src/main.rs
RUN cargo build --release
COPY src ./src
RUN touch src/main.rs && cargo build --release

Dependency Minimization

Beyond multi-stage builds, you can aggressively minimize what goes into each layer by removing caches, using precise install flags, and combining commands.

Debian/Ubuntu: --no-install-recommends

# ❌ BAD: Installs recommended (often unnecessary) packages
RUN apt-get update && apt-get install -y curl

# ✅ GOOD: Only explicit dependencies, clean cache
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl \
        ca-certificates && \
    rm -rf /var/lib/apt/lists/*

Python: Minimize pip residue

# ✅ No cache, no compile cache, disable version check
RUN pip install --no-cache-dir --no-compile --disable-pip-version-check \
    -r requirements.txt

Node.js: Production-only dependencies

# ✅ Skip devDependencies, clean npm cache
RUN npm ci --only=production && \
    npm cache clean --force

Combine RUN Instructions

# ❌ BAD: 3 layers, intermediate files persist
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ GOOD: 1 layer, cache removed in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*
Why combine? Each RUN creates a layer. Deleting files in a later layer doesn't reclaim space — the files still exist in the previous layer. You must create and delete within the same RUN instruction for the space savings to take effect.

Image Size Comparison

The following table shows the same Go HTTP server built with different strategies. The application code is identical — only the Dockerfile and base image differ.

Strategy Base Image Final Size Layers CVEs Found
Single-stage (golang) golang:1.22 843 MB 14 387
Multi-stage → ubuntu ubuntu:24.04 90 MB 6 42
Multi-stage → debian-slim debian:bookworm-slim 64 MB 5 28
Multi-stage → alpine alpine:3.20 20 MB 4 3
Multi-stage → distroless distroless/static 14.5 MB 3 0
Multi-stage → scratch scratch 12.1 MB 2 0

.dockerignore Best Practices

The .dockerignore file prevents unnecessary files from entering the build context. A well-crafted ignore file speeds up builds, prevents secrets from leaking into images, and reduces context transfer time.

# .dockerignore — comprehensive example

# Version control
.git
.gitignore
.gitattributes

# CI/CD
.github
.gitlab-ci.yml
Jenkinsfile
.circleci

# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~

# OS files
.DS_Store
Thumbs.db

# Dependencies (rebuilt inside container)
node_modules
vendor
__pycache__
*.pyc
.venv
venv

# Build artifacts
dist
build
target
*.o
*.a
*.so

# Docker files (avoid recursive builds)
Dockerfile*
docker-compose*.yml
.dockerignore

# Documentation
README.md
CHANGELOG.md
docs/
*.md

# Tests (unless you run tests in build)
tests/
test/
*_test.go
*.test.js

# Secrets — CRITICAL
.env
.env.*
*.pem
*.key
*.crt
credentials/
secrets/

# Large media files
*.mp4
*.zip
*.tar.gz
Security Critical: Always exclude .env, private keys, and credential files. Even with multi-stage builds, these files enter the build context and could be accidentally COPY'd into layers that get pushed to registries.

Exercises

Exercise 1

Convert a Single-Stage Build

Take any project you have (Node.js, Python, Go) and convert its Dockerfile to use multi-stage builds. Measure the before/after image size using docker images. Target at least a 50% size reduction.

Exercise 2

Base Image Shootout

Build the same application using ubuntu, debian-slim, alpine, and distroless as runtime bases. Compare sizes, scan each with docker scout cves or Trivy, and document the tradeoffs.

Exercise 3

Cache Optimization Challenge

Reorder a Dockerfile to maximize cache hits. Make a source code change and rebuild — only the final layers should rebuild. Use docker build --progress=plain to verify which layers are cached vs rebuilt.

Conclusion & Next Steps

Multi-stage builds are the single most impactful optimization for container images. By separating build environments from runtime, you achieve:

  • 90%+ size reduction — ship only binaries and runtime dependencies
  • Near-zero CVE counts — eliminate build tools, compilers, and package managers from production
  • Faster deployments — smaller images mean faster pulls, faster scaling, and reduced bandwidth costs
  • Better caching — strategic instruction ordering means dependency changes don't trigger full rebuilds
  • Cleaner code organization — build, test, and runtime stages are explicit and self-documenting

The choice of base image is your second most impactful decision: scratch for statically compiled languages, distroless for maximum security with convenience, alpine for minimal but debuggable images, and debian-slim when glibc compatibility is essential.

Next in the Series

In Part 9: BuildKit & Modern Build Techniques, we unlock Docker's next-generation build engine — parallel stage execution, cache mounts for package managers, secret handling that never touches image layers, SSH forwarding for private repos, and reproducible builds for supply chain security.