Docker Image Optimization for Node.js: 1GB to 80MB
Your Docker image is 1GB. Your application code is 15MB.
Every deployment is slow. Every container start is a download. Every registry scan takes time. The bloat is not your code. It’s the image you built it in.
You can cut this to 1/12th its size with four changes.
Why Docker Images Get Fat
FROM node:18WORKDIR /appCOPY . .RUN npm installEXPOSE 3000CMD ["node", "server.js"]This Dockerfile includes:
- 🔹
node:18(500MB) – full Node.js, build tools, gcc, make, git - 🔹
node_moduleswith dev dependencies (200MB) – testing, transpilers, unused fluff - 🔹
.git,.env.example, test files (50MB) – never needed in production - 🔹 Npm cache (150MB) – only used during build
Total: ~900MB for a 15MB app.
Solution 1: Multi-Stage Build
Split into two stages: build (big) and production (tiny):
# Stage 1: Builder (everything, temporary)FROM node:18-alpine AS builder
WORKDIR /appCOPY package*.json ./
# Install everything (dev + prod)RUN npm ci
COPY . .
# Build your app (TypeScript → JavaScript, webpack, etc.)RUN npm run build
# Stage 2: Production (only runtime + dist + prod deps)FROM node:18-alpine
WORKDIR /app
# Copy ONLY production dependencies from builderCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package*.json ./
# Prune dev dependencies (optional but recommended)RUN npm ci --only=production && npm cache clean --force
EXPOSE 3000CMD ["node", "dist/server.js"]| Build | Size |
|---|---|
| Single-stage (bad) | ~900MB |
| Multi-stage (good) | ~280MB |
| Multi-stage + alpine | ~150MB |
Savings: 84% reduction.
Solution 2: Use Alpine Base
Node’s default is node:18 (Debian). Alpine is 10% of the size:
# Before: node:18 (500MB Debian)FROM node:18
# After: node:18-alpine (50MB Alpine Linux)FROM node:18-alpineAlpine is minimal. Some packages fail. But for most Node apps? Works perfectly.
# If you hit Alpine compatibility issues:RUN apk add --no-cache python3 make g++Solution 3: Switch to Distroless (Advanced)
Distroless removes everything except your app and runtime:
FROM node:18-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
# Google's distroless: only Node + libc, nothing elseFROM gcr.io/distroless/nodejs18-debian11
WORKDIR /appCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package*.json ./
EXPOSE 3000CMD ["/app/dist/server.js"]| Image | Size | Boot Time |
|---|---|---|
| node:18 | 500MB | 1.2s |
| node:18-alpine | 50MB | 0.8s |
| distroless/nodejs18 | 18MB | 0.6s |
Distroless removes:
- ⚠️ No shell (can’t SSH into container)
- ⚠️ No package manager
- ⚠️ No debugging tools
Only use if your app runs standalone (it should).
Solution 4: Layer Caching & Ordering
Docker caches layers. Wrong order = cache misses = slow rebuilds:
# ❌ Bad: Code changes invalidate npm cacheCOPY . .RUN npm ciRUN npm run build
# ✔ Good: npm cache stays valid until package.json changesCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildWhy? Docker’s layer caching works like this:
- If
COPY package*.json ./hasn’t changed → cached layer used RUN npm ciis skipped (uses cached result)- If
COPY . .changed (your code) → new layer, but npm is already done
# Layer order matters:# 1. Copy dependency files (rarely change)COPY package*.json ./
# 2. Install (cached until package.json changes)RUN npm ci --only=production
# 3. Copy everything else (changes frequently)COPY . .
# 4. Build (only reruns if code changed)RUN npm run build.dockerignore Essentials
# .dockerignore - keep image smallnode_modules/.git/.github/dist/.env.env.local.env.*.local.npmrcnpm-debug.logcoverage/.DS_Store*.md.editorconfig.gitignore.prettierignore.eslintignoreThis alone saves 50MB by not copying useless files.
Production-Ready Dockerfile (Complete Example)
# Stage 1: DependenciesFROM node:18-alpine AS dependencies
WORKDIR /appCOPY package*.json ./RUN npm ci
# Stage 2: BuildFROM node:18-alpine AS builder
WORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
# Stage 3: ProductionFROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
# Copy from builder, not dependencies# This ensures we don't include dev depsCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package*.json ./
# Expose and runEXPOSE 3000ENV NODE_ENV=productionCMD ["/app/dist/server.js"]Build it:
docker build -t myapp:latest .docker image inspect myapp:latest | grep Size# Compare to bad versiondocker build -t myapp:bad -f Dockerfile.bad .# Size: ~900MBSize Comparison Table
| Approach | Final Size | Build Time | Boot Time |
|---|---|---|---|
| Single-stage node:18 | 920MB | 45s | 1.5s |
| Single-stage + alpine | 280MB | 42s | 0.9s |
| Multi-stage + alpine | 150MB | 48s | 0.8s |
| Multi-stage + distroless | 25MB | 50s | 0.5s |
Advanced: Builder Cache
For CI/CD, separate builder from production:
# Build production image, but keep builder for reusedocker build --target builder -t myapp:builder .docker build -t myapp:latest .In your CI pipeline:
# GitHub Actions- name: Build run: docker build -t myapp:${{ github.sha }} .
- name: Push run: docker push myapp:${{ github.sha }}
- name: Deploy run: kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}Each deployment pulls 25MB instead of 920MB. Registry scans finish in seconds, not minutes.
Summary
✔ Multi-stage: Separate build from runtime (80% savings) ✔ Alpine: Swap node:18 for node:18-alpine (90% smaller) ✔ Distroless: Remove shell and tools (84% smaller) ✔ Layer ordering: Copy deps first, code last (rebuild speed +200%) ✔ .dockerignore: Exclude build artifacts (50MB saved)
Start here: Multi-stage + Alpine gets you to ~150MB with zero pain. Add distroless when you’re ready to lose SSH debugging (it’s worth it).
Your deployment pipeline will thank you. And when your infra bills are based on image pulls, your wallet will too.