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:

  1. deps: Production dependencies only
  2. builder: Full dependencies + build
  3. 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.