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

Leave a Comment