Every Docker image I inherited was over 1GB. After switching to multi-stage builds, the average dropped to 150MB. Here are the exact patterns I use.
Pattern 1: Builder and Runtime Separation
The most common pattern. Build dependencies stay in the builder stage; only the compiled output copies to the runtime stage.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
Result: build stage has TypeScript compiler, test frameworks, dev dependencies. Runtime has only production code.
Pattern 2: Distroless for Compiled Languages
For Go, Rust, or any language that produces a static binary:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Distroless images have no shell, no package manager, no OS utilities. Attack surface drops to near zero. Image size: ~15MB.
Pattern 3: Python with Virtual Environment Copy
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS runtime
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Copying the entire venv avoids reinstalling packages in the runtime stage.
Size Comparison
| Approach | Image Size |
|---|---|
python:3.12 (no multi-stage) |
1.2 GB |
python:3.12-slim (no multi-stage) |
450 MB |
| Multi-stage with slim + venv copy | 180 MB |
| Go + distroless | 15 MB |
Common Mistakes
Copying everything in one COPY instruction. Each COPY creates a layer. Copy dependency files first, install, then copy source. This maximizes layer caching.
Not using
.dockerignore. Without it, you copynode_modules/,.git/, and test fixtures into the build context.Running as root. Add
USER nonrootorUSER 1000in the runtime stage.
Full Docker guide: Docker vs Kubernetes
Top comments (0)