Multi-Stage Builds

3 minute read

What Are Multi-Stage Builds?

Multi-stage builds keep your final image small by separating the build environment from the runtime environment. This is critical for production deployments — you get the tools you need to compile or bundle your code, then copy only the final artifact into a minimal runtime image.

Why Multi-Stage Builds Matter

Without Multi-Stage With Multi-Stage
Build tools in final image Only runtime artifacts
Large image (hundreds of MB) Small image (often < 20MB)
Larger attack surface Minimal attack surface
Slower pull times Fast pulls and deployments

How It Works

┌─────────────────────────────┐
│  Stage 1: BUILD             │
│  ┌───────────────────────┐  │
│  │ Full toolchain        │  │
│  │ Source code            │  │
│  │ Dependencies           │  │
│  │ Compile/Build step     │  │
│  │         ↓              │  │
│  │ Binary / Bundle        │──┼──→ COPY --from=builder
│  └───────────────────────┘  │
│  ❌ Discarded               │
└─────────────────────────────┘

┌─────────────────────────────┐
│  Stage 2: RUNTIME           │
│  ┌───────────────────────┐  │
│  │ Minimal base image    │  │
│  │ + copied artifact     │  │
│  │ = Final image ✅       │  │
│  └───────────────────────┘  │
└─────────────────────────────┘

Go Application — Multi-Stage Build

Go compiles to a single static binary, making it the perfect candidate for multi-stage builds.

# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server .

# Stage 2: Runtime (tiny final image)
FROM alpine:3.19
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]

Size comparison:

Stage Base Image Approximate Size
Build golang:1.22 ~800 MB
Final alpine:3.19 ~8 MB (binary + Alpine)

The build stage uses the full Go toolchain (~800MB). The final image uses Alpine (~5MB) with just the compiled binary, resulting in a final image under 20MB.

Using scratch for Even Smaller Images

For Go, you can use scratch (an empty image) since Go produces fully static binaries:

FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /server .

FROM scratch
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]

This produces an image that is only the size of your binary — often 5-15MB.

Note: scratch has no shell, no package manager, no debugging tools. Use it only when you’re confident the binary is fully self-contained.

Node.js — Multi-Stage Build

Node.js apps benefit from multi-stage builds to separate dependency installation from runtime:

# Stage 1: Install dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER 1000
EXPOSE 3000
CMD ["node", "server.js"]

Why Separate Dependency Installation?

  1. Caching — npm ci only reruns when package.json or package-lock.json change
  2. Cleaner image — no leftover build artifacts, npm cache, or devDependencies
  3. Reproducibility — npm ci uses the lockfile for deterministic installs

Java — Multi-Stage Build

Java applications need the JDK to compile but only the JRE to run:

# Stage 1: Build with Maven
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime with JRE only
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
USER 1000
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
Stage Image Purpose
Builder maven:3.9-eclipse-temurin-21 (~500MB) Full JDK + Maven
Runtime eclipse-temurin:21-jre-alpine (~100MB) JRE only

Python — Multi-Stage Build

Python doesn’t compile to a binary, but multi-stage builds still help to keep the final image clean:

# Stage 1: Build wheels
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
USER 1000
EXPOSE 8080
CMD ["python", "app.py"]

Key Syntax: COPY –from

The COPY --from=<stage> instruction is what makes multi-stage builds work:

# Copy from a named stage
COPY --from=builder /app/dist ./dist

# Copy from a stage by index (0-based)
COPY --from=0 /app/build ./build

# Copy from an external image
COPY --from=nginx:1.27-alpine /etc/nginx/nginx.conf /nginx.conf

CKAD relevance: While the exam focuses on Kubernetes, understanding multi-stage builds helps you reason about image sizes, security, and efficient Pod deployments.

Summary

Language Build Stage Runtime Stage Typical Savings
Go golang:1.22 (800MB) alpine or scratch (5-15MB) 98%
Node.js node:18-alpine (180MB) node:18-alpine (lean) 30-50%
Java maven + JDK (500MB) JRE-alpine (100MB) 80%
Python python:3.12-slim (150MB) python:3.12-slim (lean) 20-40%

Updated: