Docker Multi-Stage Builds
The node_modules problem in Docker
Section titled “The node_modules problem in Docker”When you containerise a Node/TypeScript application, the final image must include:
- A full Node.js runtime (100+ MB)
node_modules/(can easily be 300–600 MB for a typical NestJS app)- Your compiled
.jsoutput
The result is often a 500 MB–1 GB image that ships your entire development dependency tree into production. You mitigate this with .dockerignore, --omit=dev, and multi-stage builds — but the Node runtime itself never goes away.
Go compiles to a single statically-linked binary with no runtime dependency. The final Docker image can be built FROM scratch — a literally empty base image — containing only your binary. A typical Go production image is 5–20 MB.
The Go multi-stage Dockerfile
Section titled “The Go multi-stage Dockerfile”A multi-stage build uses a full Go builder image to compile, then copies only the resulting binary into a minimal final image.
# ---- Stage 1: Build ----FROM golang:1.22-alpine AS builder
WORKDIR /app
# Copy dependency manifests first for layer cachingCOPY go.mod go.sum ./RUN go mod download
# Copy source and build a static binaryCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# ---- Stage 2: Run ----FROM gcr.io/distroless/static-debian12
WORKDIR /app
# Copy only the binary from the builder stageCOPY --from=builder /app/server .
EXPOSE 8080
ENTRYPOINT ["/app/server"]CGO_ENABLED=0 disables C bindings so the binary is fully self-contained. -ldflags="-s -w" strips debug symbols, shaving ~30% off binary size. distroless/static is a Google-maintained minimal image that contains only CA certificates and timezone data — useful for HTTPS calls — but no shell, no package manager, no attack surface.
If you need a shell for debugging, swap distroless/static-debian12 with alpine:3.20 (still only ~7 MB) and add apk add --no-cache ca-certificates.
The Node multi-stage Dockerfile (for comparison)
Section titled “The Node multi-stage Dockerfile (for comparison)”# ---- Stage 1: Build ----FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./RUN npm ci
COPY . .RUN npm run build # tsc output → dist/
# Prune dev dependenciesRUN npm prune --omit=dev
# ---- Stage 2: Run ----FROM node:20-alpine
WORKDIR /app
# Must copy node_modules — runtime still needs themCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package.json .
EXPOSE 3000
CMD ["node", "dist/main.js"]Even after pruning dev deps, the Node runtime and remaining node_modules make this image 150–300 MB.
# Final Node image size: ~150-300 MB# Includes: node runtime + node_modules + dist/
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildRUN npm prune --omit=dev
FROM node:20-alpineWORKDIR /appCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distEXPOSE 3000CMD ["node", "dist/main.js"]# Final Go image size: ~5-20 MB# Includes: your binary only (no runtime)
FROM golang:1.22-alpine AS builderWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux \ go build -ldflags="-s -w" \ -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12WORKDIR /appCOPY --from=builder /app/server .EXPOSE 8080ENTRYPOINT ["/app/server"]These are Dockerfile examples — run them in your terminal with
docker build -t myapp .and thendocker images myappto compare sizes. No browser playground is applicable here.