How to Optimize Your Dockerfile: Smaller Images, Faster Builds (2026)
Published · Updated
A bloated Docker image costs you in three places: slower CI pipelines, higher registry storage, and longer pod startup times in Kubernetes. Most of the problem comes down to a handful of avoidable Dockerfile mistakes.
1. Use multi-stage builds
The most impactful single change. Multi-stage builds let you compile in a fat environment and ship only the runtime artifact.
Single-stage (before):
FROM node:20
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/server.js"]
# Final image: 900MB+ (node + all dev deps + source)
Multi-stage (after):
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
# Final image: ~120MB
For compiled languages (Go, Rust):
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]
# Final image: ~8MB
2. Choose the right base image
| Base image | Size | When to use |
|---|---|---|
ubuntu:24.04 | ~80MB | Dev/debug only — never production |
node:20 | ~1.1GB | Build stage only |
node:20-alpine | ~57MB | Most Node production images |
python:3.12-slim | ~130MB | Python when Alpine has C-ext issues |
python:3.12-alpine | ~50MB | Python without C extensions |
gcr.io/distroless/nodejs20 | ~112MB | Hardened production Node |
gcr.io/distroless/static | ~2MB | Compiled binaries (Go, Rust) |
Use alpine by default. Switch to distroless when security scanning is a hard requirement and you don’t need a shell in production containers.
3. Order layers for cache efficiency
Docker caches each layer. A layer is invalidated when its instruction changes — and all subsequent layers rebuild too. The fix: put things that change rarely at the top.
Cache-busting order (bad):
FROM node:20-alpine
COPY . . # ← invalidates cache on every code change
RUN npm install # ← reinstalls all packages every build
Cache-preserving order (good):
FROM node:20-alpine
COPY package*.json ./ # ← only changes when deps change
RUN npm ci # ← cached between code-only changes
COPY . . # ← code changes here, deps already cached
Same pattern for Python:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
4. Combine RUN commands into a single layer
Each RUN creates a new image layer. The classic apt pattern creates three layers when it should be one:
# Bad — 3 layers, apt cache persists in layer 2
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*
# Good — 1 layer, cache cleared in same step
RUN apt-get update && \
apt-get install -y --no-install-recommends curl git && \
rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag alone can cut 30–50% from apt installs.
5. Use .dockerignore
The build context (everything Docker sends to the daemon before building) is often the biggest hidden bottleneck. Without .dockerignore:
node_modules/ → 200MB sent on every build
.git/ → entire repo history sent
*.log → log files included in image
dist/ → old build artifacts interfere
Minimal .dockerignore:
.git
.gitignore
node_modules
npm-debug.log
dist
.env
.env.*
coverage
*.test.ts
Dockerfile*
README.md
For Python:
__pycache__
*.pyc
*.pyo
.venv
venv
.pytest_cache
.mypy_cache
6. Pin versions, never use latest
FROM node:latest breaks silently when the upstream image updates. In CI, this makes builds non-reproducible.
# Bad
FROM node:latest
FROM python:latest
# Good
FROM node:20.15.0-alpine3.20
FROM python:3.12.4-slim-bookworm
For production images, also add --platform to ensure consistent builds across Mac (ARM) and Linux CI (x86):
FROM --platform=linux/amd64 node:20.15.0-alpine3.20
Quick audit checklist
Before pushing any Dockerfile to production:
- Multi-stage build — build tools not in final image
- Base image is Alpine or Distroless, not
ubuntuor full language image -
COPY package*.jsonbeforeRUN npm install(or equivalent) -
RUNcommands combined with&&, cache cleaned in same layer -
.dockerignoreexcludesnode_modules,.git, logs - Versions pinned — no
latesttags -
docker images myapp— final image under 200MB for typical apps
Related guides
- Why Your Pods Are Restarting — probe misconfigurations that cause deploy-time failures
- Kubernetes ConfigMaps & Secrets — inject config without baking it into images
- ArgoCD vs GitHub Actions for Indian Startups — decide how to deploy the image once it’s built