Skip to content

Docker Multi-Stage Builds

When you containerise a Node/TypeScript application, the final image must include:

  1. A full Node.js runtime (100+ MB)
  2. node_modules/ (can easily be 300–600 MB for a typical NestJS app)
  3. Your compiled .js output

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.

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 caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build a static binary
COPY . .
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 stage
COPY --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 dependencies
RUN npm prune --omit=dev
# ---- Stage 2: Run ----
FROM node:20-alpine
WORKDIR /app
# Must copy node_modules — runtime still needs them
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --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.

TypeScript
# Final Node image size: ~150-300 MB
# Includes: node runtime + node_modules + dist/
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main.js"]
Go
# Final Go image size: ~5-20 MB
# Includes: your binary only (no runtime)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -ldflags="-s -w" \
-o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
ENTRYPOINT ["/app/server"]

These are Dockerfile examples — run them in your terminal with docker build -t myapp . and then docker images myapp to compare sizes. No browser playground is applicable here.

What does `CGO_ENABLED=0` do when building a Go binary for Docker?
Why can a Go Docker image use `FROM scratch` while a Node image cannot?
What is the purpose of `-ldflags="-s -w"` in the Go build command?
Which multi-stage Docker image is recommended for Go production services that need CA certificates?