Shrinking Docker Images: Multi-stage Builds and Distroless Techniques
Large container images cost storage, pull time, and attack surface (CVEs). The most effective pattern to shrink a Docker image is a multi-stage build; pairing it with a distroless or minimal runtime base is the second lever.
What is multi-stage?
The first stage contains compilers and dev tools; the final stage copies only what you need to run. The final image does not ship git, build-essential, or dev node_modules.
Go example
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
CGO_ENABLED=0 helps produce a static binary suitable for distroless (project-dependent).
Node.js sketch
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
Distroless has no shell — great for prod, harder to exec debug; keep a separate debug target if needed.
Distroless vs Alpine
| Base | Size | Notes |
|---|---|---|
| distroless | Very small | No package manager, smaller surface |
| alpine | Small | musl quirks for some native deps |
| debian-slim | Medium | Easier debugging |
Alpine is tempting; glibc-expecting binaries can surprise you. Static Go builds + distroless are a common combo.
Layer caching
Put COPY go.mod go.sum and RUN go mod download early so dependency layers rarely invalidate. Same idea for package.json + npm ci.
Security
Smaller images mean fewer packages and fewer CVEs. Still rebuild bases regularly and run scanners (docker scout, registry scans) in CI.
Summary
- Multi-stage builds keep toolchains out of the final image.
- Distroless or minimal bases plus static binaries shrink Docker images and attack surface.
- Be careful with Alpine; distroless/static + static Go is a proven default.
Pair with a lean data path — e.g. PgBouncer connection pooling — for end-to-end efficiency.
Related posts
StatefulSet vs Deployment: Critical Differences and Practical Decisions
A deep technical comparison of Kubernetes StatefulSet and Deployment, including workload fit, identity and storage behavior, rollout risks, and production-critical best practices.
JWT Authentication in Go: Access Tokens, Refresh Tokens, and Secure Storage
Sign and verify JWTs in Go; short-lived access tokens, refresh rotation, HttpOnly cookies, and common pitfalls.
gRPC vs REST: When Should You Use Which? A Comparative Guide with Go
gRPC and REST in microservices: protobuf, HTTP/2, browser constraints, and Go examples — complements our Go vs Node.js service comparison.