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 imageSizeWhen to use
ubuntu:24.04~80MBDev/debug only — never production
node:20~1.1GBBuild stage only
node:20-alpine~57MBMost Node production images
python:3.12-slim~130MBPython when Alpine has C-ext issues
python:3.12-alpine~50MBPython without C extensions
gcr.io/distroless/nodejs20~112MBHardened production Node
gcr.io/distroless/static~2MBCompiled 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 ubuntu or full language image
  • COPY package*.json before RUN npm install (or equivalent)
  • RUN commands combined with &&, cache cleaned in same layer
  • .dockerignore excludes node_modules, .git, logs
  • Versions pinned — no latest tags
  • docker images myapp — final image under 200MB for typical apps