Skip to main content
btheo.com btheo.com > press start to play
NEW POST: NODE.JS SECURITY 2025 OPEN FOR FREELANCE 10+ YEARS EXP REACT × NODE × AWS NEW POST: NODE.JS SECURITY 2025 OPEN FOR FREELANCE 10+ YEARS EXP REACT × NODE × AWS
DOCKER 5 MIN READ

Docker Image Optimization for Node.js: 1GB to 80MB

WARNING · DRAGON AHEAD

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

Terminal window
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

This Dockerfile includes:

  • 🔹 node:18 (500MB) – full Node.js, build tools, gcc, make, git
  • 🔹 node_modules with 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 /app
COPY 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 builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
# Prune dev dependencies (optional but recommended)
RUN npm ci --only=production && npm cache clean --force
EXPOSE 3000
CMD ["node", "dist/server.js"]
BuildSize
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-alpine

Alpine is minimal. Some packages fail. But for most Node apps? Works perfectly.

Terminal window
# 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 builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Google's distroless: only Node + libc, nothing else
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["/app/dist/server.js"]
ImageSizeBoot Time
node:18500MB1.2s
node:18-alpine50MB0.8s
distroless/nodejs1818MB0.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 cache
COPY . .
RUN npm ci
RUN npm run build
# ✔ Good: npm cache stays valid until package.json changes
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

Why? Docker’s layer caching works like this:

  1. If COPY package*.json ./ hasn’t changed → cached layer used
  2. RUN npm ci is skipped (uses cached result)
  3. 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 small
node_modules/
.git/
.github/
dist/
.env
.env.local
.env.*.local
.npmrc
npm-debug.log
coverage/
.DS_Store
*.md
.editorconfig
.gitignore
.prettierignore
.eslintignore

This alone saves 50MB by not copying useless files.

Production-Ready Dockerfile (Complete Example)

# Stage 1: Dependencies
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
# Copy from builder, not dependencies
# This ensures we don't include dev deps
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
# Expose and run
EXPOSE 3000
ENV NODE_ENV=production
CMD ["/app/dist/server.js"]

Build it:

~25MB
docker build -t myapp:latest .
docker image inspect myapp:latest | grep Size
# Compare to bad version
docker build -t myapp:bad -f Dockerfile.bad .
# Size: ~900MB

Size Comparison Table

ApproachFinal SizeBuild TimeBoot Time
Single-stage node:18920MB45s1.5s
Single-stage + alpine280MB42s0.9s
Multi-stage + alpine150MB48s0.8s
Multi-stage + distroless25MB50s0.5s

Advanced: Builder Cache

For CI/CD, separate builder from production:

Terminal window
# Build production image, but keep builder for reuse
docker 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.

ALL POSTS →