A typical Go application compiles to a single binary. Yet Docker images for Go apps often weigh hundreds of megabytes. Why? Because the image includes the entire Go toolchain used to build it.
Multi-stage builds solve this: use one stage to build, another to run. The final image contains only what’s needed at runtime.
The Problem: Fat Images#
1
2
3
4
5
6
7
8
| # Single-stage - includes everything
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
|
This image includes:
- Go compiler (~500MB)
- Build cache
- Source code
- Test files
- Development dependencies
Final size: 800MB+ for a 10MB binary.
Multi-Stage Solution#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # Stage 1: Build
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: Run
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/server .
CMD ["./server"]
|
Final size: ~15MB. The Go toolchain stays in the builder stage, never making it to the final image.
How It Works#
Each FROM starts a new stage. Stages can be named with AS:
1
2
3
4
5
6
7
8
| FROM node:20 AS deps
# Install dependencies
FROM node:20 AS builder
# Build application
FROM node:20-slim AS runner
# Run application
|
Use COPY --from=stagename to copy artifacts between stages:
1
2
| COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
|
Only the final stage becomes your image. Previous stages are discarded.
Node.js Example#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Run
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER node
CMD ["node", "dist/index.js"]
|
Three stages:
- deps: Production dependencies only
- builder: Full dependencies + build
- runner: Production deps + built artifacts
Python Example#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # Stage 1: Build wheels
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Stage 2: Run
FROM python:3.12-slim AS runner
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
USER nobody
CMD ["python", "app.py"]
|
Build tools stay in builder stage. Final image has only Python runtime and pre-built wheels.
Distroless Images#
Go even leaner with distroless bases:
1
2
3
4
5
6
7
8
| FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /
CMD ["/server"]
|
Distroless images contain only your application and runtime dependencies. No shell, no package manager, no utilities attackers could exploit.
Final size: ~5MB for a static Go binary.
Targeting Specific Stages#
Build a specific stage for debugging:
1
2
3
4
5
| # Build only the builder stage
docker build --target builder -t myapp:builder .
# Run tests in builder stage
docker run myapp:builder npm test
|
Useful for CI pipelines where you need build artifacts without the final image.
Caching Optimization#
Order matters for layer caching:
1
2
3
4
5
6
7
8
| # Bad: Any source change invalidates npm install cache
COPY . .
RUN npm install
# Good: Dependencies cached unless package.json changes
COPY package*.json ./
RUN npm install
COPY . .
|
With multi-stage, you can optimize caching per stage:
1
2
3
4
5
6
7
8
9
10
| FROM node:20 AS deps
COPY package*.json ./
RUN npm ci
# This stage is cached if package.json unchanged
FROM node:20 AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Only this rebuilds when source changes
|
Build Arguments Across Stages#
Pass build args to specific stages:
1
2
3
4
5
| ARG NODE_ENV=production
FROM node:20 AS builder
ARG NODE_ENV
RUN echo "Building for ${NODE_ENV}"
|
Note: ARG doesn’t automatically carry across stages. Redeclare it in each stage that needs it.
Secrets in Build (BuildKit)#
Don’t embed secrets in images. Use BuildKit secrets:
1
2
3
4
5
| # syntax=docker/dockerfile:1
FROM node:20 AS builder
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
|
1
| docker build --secret id=npm_token,src=.npmrc .
|
The secret is available during build but not stored in any layer.
Common Patterns#
Static binary from Alpine#
1
2
3
4
5
6
7
8
9
| FROM golang:1.21-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY . .
RUN go build -ldflags="-s -w" -o app .
FROM scratch
COPY --from=builder /app/app /
ENTRYPOINT ["/app"]
|
Copy from external images#
1
2
3
4
| FROM nginx:alpine
# Copy from a completely different image
COPY --from=busybox:latest /bin/wget /usr/local/bin/
|
Development vs Production#
1
2
3
4
5
6
7
8
9
10
11
12
| FROM node:20 AS base
WORKDIR /app
COPY package*.json ./
FROM base AS development
RUN npm install
CMD ["npm", "run", "dev"]
FROM base AS production
RUN npm ci --only=production
COPY . .
CMD ["npm", "start"]
|
1
2
| docker build --target development -t myapp:dev .
docker build --target production -t myapp:prod .
|
Debugging Multi-Stage Builds#
See what’s in intermediate stages:
1
2
3
4
5
6
| # List all stages
docker build --target builder -t debug .
docker run -it debug sh
# Check image size per stage
docker images | grep myapp
|
Use docker history to see layer sizes:
1
| docker history myapp:latest
|
Multi-stage builds transform bloated development images into lean production artifacts. Build tools compile your code, then disappear. The final image contains exactly what’s needed to run — nothing more.
Smaller images mean faster pulls, reduced storage costs, and smaller attack surface. There’s no downside, only discipline: separate what you need to build from what you need to run.