Deploying a 1.5GB Docker container to production is painful. Large images slow down CI/CD pipelines, increase container registry storage costs, and prolong Kubernetes deployment rolling updates.
More importantly, bloated containers represent a massive security liability. If your production runtime image contains compilers (gcc, g++), package managers (npm, pip), and development header files, an attacker who gains remote code execution has all the tools necessary to download, compile, and execute malicious native exploits right on your host.
The solution is Docker Multi-Stage Builds. In this tutorial, we will take a real Node.js and Go application and slash the container footprint by over 95%.
How Multi-Stage Builds Work
Multi-stage builds allow you to declare multiple FROM instructions in a single Dockerfile. Each FROM begins a completely new stage with a clean base image.
Crucially, you can selectively copy only the compiled build artifacts from one stage to another, leaving behind the SDKs, compilers, and intermediate build tools.
+--------------------------------------------------------+
| Stage 1: Build & Compile (node:20-alpine) |
| - TypeScript Compiler |
| - devDependencies (ESLint, Prettier) |
| - Generates: /dist/bundle.js (Artifact) |
+--------------------------------------------------------+
|
(COPY --from=builder /dist)
v
+--------------------------------------------------------+
| Stage 2: Production Runtime (node:20-alpine-minimal) |
| - Zero compilers, zero devDependencies |
| - Only production node_modules |
| - Final Size: 58 MB! |
+--------------------------------------------------------+
Production Node.js Multi-Stage Dockerfile
# STAGE 1: Install Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# STAGE 2: Build & Compile TypeScript
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npm prune --production
# STAGE 3: Production Runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
USER node
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
Extreme Optimization: Go and Rust with Distroless / Scratch
For compiled languages like Go, Rust, or C++, you do not even need an operating system or shell at runtime!
You can compile a statically linked binary and deploy it into Google's Distroless image or an empty scratch image.
# Stage 1: Compile Statically Linked Go Binary
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/api-server .
# Stage 2: Pure Scratch Runtime
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /bin/api-server /app/api-server
USER nonroot:nonroot
ENTRYPOINT ["/app/api-server"]
The Benchmark: Size Comparison
Original Single-Stage Go Image: ~1.15 GB
Multi-Stage with Alpine: ~28.4 MB
Multi-Stage with Distroless: ~14.2 MB (98.7% Reduction!)

Top comments (0)