Multi Stage Docker Builds for Smaller Images
Most applications need tools to build but not to run. A Java app needs the JDK (Java Development Kit) to compile but only the JRE (Java Runtime) to execute. A Go app needs the Go compiler to build but the final binary runs without Go installed at all. Multi-stage builds let you separate "build time" tools from "run time" tools, producing much smaller final images.
The Problem With Single-Stage Builds
Imagine you hire a construction crew to build your house. They bring heavy machinery — cranes, drills, concrete mixers. When the house is finished, would you leave the construction machinery inside your living room permanently? Of course not. But that is exactly what a single-stage Docker build does — it keeps all the build tools in the final image.
Single-stage build problem: FROM node:20 ← 1.1 GB base image WORKDIR /app COPY package*.json . RUN npm install ← installs 200+ dev dependencies COPY . . RUN npm run build ← compiles code to /app/dist CMD ["node", "dist/server.js"] Final image size: ~1.4 GB Reality: only /app/dist/server.js is needed at runtime
Multi-Stage Build — The Solution
A multi-stage Dockerfile has multiple FROM instructions. Each FROM starts a new stage. You copy only what you need from one stage to the next. Docker discards everything else.
# ─── Stage 1: BUILD ─────────────────────────────────────── FROM node:20 AS builder WORKDIR /app COPY package*.json . RUN npm install COPY . . RUN npm run build # Stage 1 produces: /app/dist/ # ─── Stage 2: PRODUCTION ────────────────────────────────── FROM node:20-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
How It Works — A Visual Walkthrough
Stage 1 (builder) Stage 2 (final)
┌────────────────────┐ ┌──────────────────────┐
│ node:20 (1.1 GB) │ │node:20-alpine (170MB)│
│ npm install │ │ │
│ (dev + prod deps) │ COPY only │ /app/dist/ │
│ npm run build │ what's │ (compiled code) │
│ │ needed ──→│ │
│ /app/dist/ ─────┼──────────→│ /app/node_modules/ │
│ /app/node_modules/ │ │ (production only) │
│ /app/src/ │ ✗ │ │
│ /app/*.test.js │ ✗ │ │
└────────────────────┘ └──────────────────────┘
Discarded entirely Final image: ~250 MB
Saved: ~1.15 GB
The COPY --from Syntax
COPY --from=builder /app/dist ./dist
↑ ↑ ↑
stage name path in destination in
(the AS name stage 1 current stage
in FROM)
You can also reference a stage by its index number (0, 1, 2...) instead of a name, but using names like builder makes the Dockerfile much easier to read.
Python Multi-Stage Build Example
# Stage 1: Build and install dependencies FROM python:3.11 AS builder WORKDIR /app COPY requirements.txt . RUN pip install --user -r requirements.txt # Stage 2: Lean production image FROM python:3.11-slim WORKDIR /app COPY --from=builder /root/.local /root/.local COPY . . ENV PATH=/root/.local/bin:$PATH CMD ["python", "app.py"]
Size comparison: python:3.11 base = 1.01 GB python:3.11-slim base = 125 MB Savings = ~875 MB just from the base image choice
Go Multi-Stage Build — Extreme Size Reduction
Go compiles to a single static binary. The final image can be almost empty:
# Stage 1: Compile the Go binary FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o server . # Stage 2: Scratch — literally empty image FROM scratch COPY --from=builder /app/server /server CMD ["/server"]
golang:1.21 image = 814 MB Final scratch image = ~8 MB ← just the binary!
The scratch image is completely empty — it has no shell, no operating system utilities, no nothing. This is the absolute minimum possible image size.
When to Use Multi-Stage Builds
- Any compiled language: Go, Java, C++, Rust, TypeScript
- Frontend apps that require a build step (React, Vue, Angular)
- Any project where you want to keep build tools out of production
- Security-sensitive deployments where a smaller attack surface matters
