TL;DR
- Multi-stage builds reduce image size by 90% — separate build tools from runtime.
- Each
FROMstarts a new stage. Only the final stage ships to production. - Use BuildKit cache mounts to speed up dependency installation.
- Order layers from least → most frequently changing for maximum cache hits.
Step 1: Why Multi-Stage?
Multi-stage builds were introduced in Docker 17.05 to solve a fundamental problem: build tools (compilers, dev dependencies, test frameworks) bloat production images but are needed during the build. Before multi-stage, teams either shipped 1GB+ images with all build tools included, or maintained separate Dockerfiles and shell scripts to copy artifacts between stages. Multi-stage lets you use multiple FROM statements in one Dockerfile, building in a full environment and copying only the final artifacts into a minimal runtime image.
The Problem
A single-stage build includes everything: compilers, dev dependencies, source code, build artifacts.
# ❌ Single stage — 1.2GB image
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/main.js"]
# Includes: TypeScript, dev deps, source code, test files — all unnecessary at runtime
The Solution
Split into build and runtime stages:
# ✅ Multi-stage — 150MB image (90% smaller)
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime (only production dependencies + compiled output)
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
Step 2: Optimized Node.js Dockerfile (Production)
This pattern separates dependency installation from code copying, maximizing Docker's layer cache. Dependencies change rarely compared to source code, so installing them first means Docker reuses the cached node_modules layer on every code change. The production stage uses npm ci --omit=dev to exclude devDependencies, and the final image only contains runtime code — no TypeScript compiler, no test frameworks, no build tools.
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:20-alpine AS deps
WORKDIR /app
# Only copy package files first (for better caching)
COPY package.json package-lock.json ./
# Install ALL dependencies (needed for build)
RUN npm ci
# ============================================
# Stage 2: Build the application
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build (TypeScript compile, Next.js build, etc.)
RUN npm run build
# Remove dev dependencies after build
RUN npm prune --production
# ============================================
# Stage 3: Production runtime
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
# Security: Don't run as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
# Only copy what's needed to run
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./
USER appuser
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/main.js"]
Step 3: Next.js Standalone Output (Optimal)
Next.js's standalone output mode was purpose-built for containerized deployments. It traces your application's actual imports and produces a self-contained folder with only the files needed to run — no node_modules directory, no unused pages, just the minimal runtime. This typically reduces image size from 500MB+ to under 100MB, dramatically improving cold-start times in serverless/edge environments and reducing container registry storage costs.
Next.js has a standalone mode that creates a minimal production bundle:
# Next.js 16 Optimized Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Enable standalone output in next.config.ts:
# output: "standalone"
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Next.js standalone output — contains only production files
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Result: ~150MB image instead of 1GB+.
Step 4: BuildKit Cache Mounts
Cache mounts solve the problem of repeated dependency downloads across builds. Normally, every RUN npm install re-downloads all packages from scratch because Docker layers are immutable. BuildKit's --mount=type=cache creates a persistent directory that survives between builds, so package managers reuse their cache (npm's .npm, pip's cache, apt's archives). This can cut build times by 50-80% for dependency-heavy projects.
BuildKit (Docker's modern build backend, default since Engine 23) provides cache mounts that survive between builds:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# Cache npm downloads between builds — massive speedup
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
Types of Mounts
# Cache mount — persists between builds (npm, pip, apt cache)
RUN --mount=type=cache,target=/root/.npm npm ci
# Secret mount — inject secrets without leaking to image layers
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
# Bind mount — mount files from build context without COPY
RUN --mount=type=bind,source=package.json,target=package.json \
npm ci
Using Secrets Safely
# Build with secret
docker build --secret id=npmrc,src=.npmrc -t myapp .
# In Dockerfile — secret is never saved in any layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --registry=https://private-registry.example.com
Step 5: Layer Optimization Rules
Understanding Docker's layer caching is the single biggest lever for build performance. Every instruction creates a layer, and when any layer changes, all subsequent layers are rebuilt. This means putting rarely-changing operations first (OS packages, dependencies) and frequently-changing operations last (source code copy, build commands). Getting this wrong means every code change triggers a full npm install, turning 10-second builds into 2-minute builds.
Docker caches layers from top to bottom. When a layer changes, all layers below it are invalidated.
Rule: Order from least → most changing
# ✅ GOOD — dependencies change less often than source code
FROM node:20-alpine
WORKDIR /app
# Layer 1: Dependencies (cached unless package.json changes)
COPY package.json package-lock.json ./
RUN npm ci
# Layer 2: Source code (changes frequently — only this rebuilds)
COPY . .
RUN npm run build
# ❌ BAD — COPY . . before npm ci means deps reinstall on every code change
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
.dockerignore
# .dockerignore — exclude files from build context
node_modules
.next
.git
*.md
.env*
coverage
tests
Step 6: Image Size Comparison
Image size directly impacts deployment speed, registry costs, and security surface area. Smaller images pull faster (critical for auto-scaling and CI/CD), store cheaper, and have fewer packages that could contain vulnerabilities. This comparison shows the dramatic differences between approaches — a naive Dockerfile might produce a 1.2GB image while an optimized multi-stage build with distroless base produces under 100MB for the same application.
| Approach | Image Size | Build Time |
|---|---|---|
| Single stage (node:20) | ~1.2GB | 60s |
| Multi-stage (node:20-alpine) | ~150MB | 45s |
| Multi-stage + prune | ~120MB | 50s |
| Next.js standalone | ~150MB | 40s |
| Distroless base | ~80MB | 55s |
Using Distroless (Smallest possible)
# Final stage uses Google's distroless — no shell, no package manager
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/main.js"]
Interview Questions
-
What's a multi-stage build?
- A Dockerfile with multiple
FROMinstructions. EachFROMstarts a new stage. YouCOPY --from=<stage>artifacts between stages, keeping the final image minimal.
- A Dockerfile with multiple
-
How do you optimize Docker layer caching?
- Order instructions from least to most frequently changing. Copy
package.json+ install deps before copying source code.
- Order instructions from least to most frequently changing. Copy
-
What are BuildKit cache mounts?
- Persistent caches (
--mount=type=cache) that survive between builds. Store package manager downloads (npm, pip) so they don't re-download.
- Persistent caches (
-
How do you handle secrets in Docker builds?
- Use BuildKit secret mounts (
--mount=type=secret). Never useARGorENVfor secrets — they persist in image layers.
- Use BuildKit secret mounts (