Your Docker images are probably too big. Build tools, dev dependencies, source files—all shipping to production when only the compiled binary matters. Multi-stage builds fix this by separating build environment from runtime environment.

The Problem

A typical single-stage Dockerfile:

1
2
3
4
5
6
7
8
9
FROM python:3.11

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

CMD ["python", "app.py"]

This image includes:

  • Full Python installation (~900MB)
  • pip and setuptools
  • All your source files
  • Build artifacts and cache
  • Potentially your .git directory

Result: A 1.2GB image for a 50MB application.

The Solution: Multi-Stage Builds

Separate building from running:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Stage 1: Build
FROM python:3.11 AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt

COPY . .

# Stage 2: Runtime
FROM python:3.11-slim

WORKDIR /app

# Copy only what we need from builder
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app/*.py /app/

ENV PATH=/root/.local/bin:$PATH

CMD ["python", "app.py"]

Result: ~150MB instead of 1.2GB.

Go: The Ideal Case

Go’s static binaries make this even better:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Build stage
FROM golang:1.21 AS builder

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .

# Runtime stage - scratch is empty!
FROM scratch

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

ENTRYPOINT ["/server"]

Result: ~10-20MB image containing just your binary and CA certificates.

Node.js: Production Dependencies Only

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Build stage
FROM node:20 AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Production stage
FROM node:20-slim

WORKDIR /app

# Only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy built assets
COPY --from=builder /app/dist ./dist

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

Dev dependencies (TypeScript, test frameworks, linters) stay in the build stage.

Rust: Compile Once, Run Anywhere

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Build stage
FROM rust:1.75 AS builder

WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src

RUN cargo build --release

# Runtime stage
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/myapp /usr/local/bin/

CMD ["myapp"]

Or use scratch for fully static builds with musl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM rust:1.75 AS builder

RUN rustup target add x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y musl-tools

WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /
CMD ["/myapp"]

Caching Dependencies

Layer ordering matters for cache efficiency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM python:3.11 AS builder

WORKDIR /app

# Dependencies change less often than code
COPY requirements.txt .
RUN pip install --user -r requirements.txt

# Code changes frequently
COPY . .

If you COPY . . before installing dependencies, every code change invalidates the pip cache.

Named Stages for Clarity

Use names instead of indices:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

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

FROM node:20 AS tester
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm test

FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Build specific stages:

1
2
docker build --target tester -t myapp:test .
docker build --target runner -t myapp:prod .

Security Scanning Stage

Add vulnerability scanning as a stage:

1
2
3
4
5
6
FROM aquasec/trivy AS scanner
COPY --from=builder /app/server /scan/server
RUN trivy filesystem --exit-code 1 --severity HIGH,CRITICAL /scan

FROM scratch AS final
COPY --from=builder /app/server /server

The build fails if critical vulnerabilities are found.

BuildKit Secrets

Never put secrets in your Dockerfile:

1
2
3
4
5
6
7
# syntax=docker/dockerfile:1.4

FROM python:3.11 AS builder

# Mount secret at build time, not baked into layer
RUN --mount=type=secret,id=pip_conf,target=/root/.pip/pip.conf \
    pip install --user -r requirements.txt

Build with:

1
DOCKER_BUILDKIT=1 docker build --secret id=pip_conf,src=./pip.conf .

Size Comparison

LanguageSingle StageMulti-StageReduction
Python1.2GB150MB88%
Node.js1.1GB200MB82%
Go800MB15MB98%
Rust1.5GB20MB99%

Quick Checklist

Before shipping your Dockerfile:

  • Build dependencies in separate stage from runtime
  • Use slim/alpine/distroless base images for runtime
  • Copy only necessary files to final stage
  • Install production dependencies only
  • Order layers for optimal caching
  • Use .dockerignore to exclude unnecessary files
  • Consider scratch for static binaries
  • Don’t embed secrets—use BuildKit mounts

Smaller images mean faster pulls, faster deploys, smaller attack surface, and lower storage costs. Multi-stage builds get you there without sacrificing your development workflow.