Docker Multi-Stage Builds: Reduce Image Size by 90% with Smart Layering

Docker images can balloon to 500MB+ when built naively. Multi-stage builds reduce this to 50MB by separating build dependencies from runtime artifacts. This guide shows you how.
The Problem: Bloated Images#
# ❌ Bad: Everything in one stage
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
Final image: 450MB
- Node.js compiler/build tools: 200MB
- npm cache: 150MB
- Source code: 50MB
- Dependencies: 50MB
You ship the compiler to production, which is wasteful.
The Solution: Multi-Stage Builds#
# Stage 1: Builder
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Compile
FROM node:18 AS compiler
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Runtime (minimal)
FROM node:18-alpine
WORKDIR /app
COPY --from=compiler /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]
Final image: 85MB (81% reduction!)
Why This Works#
- Stage 1 (Builder): Installs only production dependencies
- Stage 2 (Compiler): Builds code (TypeScript → JavaScript) but doesn't ship
- Stage 3 (Runtime): Copies only built artifacts + runtime deps
The compiler, dev dependencies, and build cache never touch the final image.
Advanced Pattern: Frontend Builds#
# Node stage: Build React/Next.js app
FROM node:18 AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Nginx stage: Serve static files
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend /app/.next/static /usr/share/nginx/html
COPY --from=frontend /app/public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Image size: 30MB (vs 600MB with Node.js runtime)
Layer Caching Optimization#
Docker caches layers. Order matters:
# ❌ Bad: Changes to code invalidate dependency cache
FROM node:18
WORKDIR /app
COPY . . # Code changes here...
RUN npm ci # ...invalidates cache here
# ✅ Good: Dependencies cached longer
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm ci # Only runs when package.json changes
COPY . . # Code changes don't affect cache
With good ordering, rebuilds drop from 5 minutes to 10 seconds.
Production-Ready Pattern#
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm run test || true
FROM node:18-alpine
WORKDIR /app
# Security: Non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Copy artifacts
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json .
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
CMD ["node", "dist/index.js"]
Security additions:
- Non-root user (prevents escape attacks)
- Health checks (container orchestration readiness)
- File ownership (prevents privilege escalation)
Size Reduction Techniques#
1. Alpine Linux#
FROM node:18-alpine
# Instead of:
# FROM node:18
# Saves: 200MB
Alpine is 100MB vs Node.js 300MB+.
2. Remove Unnecessary Files#
RUN npm ci && \
npm cache clean --force && \
rm -rf /tmp/* /var/cache/* /var/log/*
Clears cache after install. Saves 50-100MB.
3. Distroless Images#
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM gcr.io/distroless/nodejs18-debian11
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
CMD ["dist/index.js"]
Distroless: 50MB vs Alpine: 120MB. No shell, no package manager—minimal attack surface.
Benchmarking Impact#
# Before multi-stage
$ docker build -t app:old .
$ docker images app:old
REPOSITORY TAG SIZE
app old 450MB
# After multi-stage
$ docker build -t app:new .
$ docker images app:new
REPOSITORY TAG SIZE
app new 85MB
# Pull time improvement (1 Mbps connection)
Before: 450MB ÷ 1 Mbps = 6 minutes
After: 85MB ÷ 1 Mbps = 68 seconds
Checklist#
- Separate build stage from runtime
- Use
AS builderlabels for clarity - Order Dockerfile: dependencies, code, build
- Use Alpine or distroless base images
- Clean npm cache after install
- Run tests in builder stage
- Copy only necessary artifacts (dist/, node_modules)
- Add health checks
- Use non-root user
- Test locally with
docker build --progress=plain
Common Mistakes#
❌ Copying entire node_modules from builder (defeats purpose)
❌ Using latest tags (no reproducibility)
❌ Forgetting layer caching order
❌ Installing dev dependencies in production stage
Conclusion#
Multi-stage builds are Docker's best-kept secret. A well-structured Dockerfile reduces image size by 80-90%, cuts deployment time, and improves security.
Start with the pattern above. Measure with docker images before/after. The difference is dramatic.
Container images bloating your deploy times and registry costs? Book a 15-minute call.
FAQ
Do multi-stage builds slow down CI builds?
No — with layer caching configured correctly, only the stages affected by a given change rebuild, which is usually faster than a single-stage build re-installing everything every time.
Can I use multi-stage builds with any language or framework?
Yes. The pattern (build stage with full toolchain, runtime stage with only the compiled artifact) works for Node, Go, Java, Python, and frontend builds alike — it's a Dockerfile pattern, not a language feature.
What's a realistic image size reduction to expect?
80-90% is typical going from a naive single-stage Dockerfile to a properly layered multi-stage build with a slim runtime base image.
RelatedArticles
Continue exploring production engineering guides, systems architecture, and cloud patterns.


