Every megabyte in your image multiplies across every node, every pull, every deploy. Learn to slash image sizes by 10x and cut build times in half.
1. Why Size Matters
Image size isn't vanity — it's operational cost that compounds at scale:
- Slower pulls — a 1GB image on a 1Gbps link takes ~8 seconds. On 100 nodes during a rollout, that's 100GB transferred.
- Slower CI/CD — every pipeline run pushes/pulls images. Smaller = faster feedback loops.
- Storage cost — registries charge per GB. 50 tags × 1GB = 50GB per service.
- Larger attack surface — more binaries = more CVEs. A shell in your image is a gift to attackers.
- Slower autoscaling — cold-start on a new node is dominated by image pull time. In Kubernetes, a pod can't start until its image is available.
2. Measuring Image Size
Before optimizing, measure. Several tools help you understand where the bytes are:
# List local images with sizes
docker images
# REPOSITORY TAG SIZE
# myapp latest 943MB
# Show layer-by-layer breakdown
docker history myapp:latest
# IMAGE CREATED BY SIZE
# a1b2c3d4 COPY . /app 245MB
# e5f6g7h8 RUN apt-get install -y build-essential... 312MB
# i9j0k1l2 FROM ubuntu:22.04 77MB
# Remote image size (without pulling)
docker manifest inspect myapp:latest | jq '.config.size'
# Deep exploration with dive
dive myapp:latest
# Interactive TUI — shows each layer, wasted space, efficiency score
brew install dive or download from GitHub. It shows a layer-by-layer filesystem diff and highlights wasted space (files added then removed in later layers).
3. Base Image Selection
Your base image is the foundation. Choosing wisely gives you a massive head start:
| Base Image | Size | Packages | Vuln Count* | Best For |
|---|---|---|---|---|
ubuntu:22.04 |
77 MB | ~90 | 42 | Development, debugging |
debian:bookworm-slim |
52 MB | ~60 | 28 | General production |
alpine:3.19 |
7 MB | ~15 | 3 | Small footprint apps |
gcr.io/distroless/static |
2 MB | 0 (no shell) | 0 | Static binaries (Go, Rust) |
scratch |
0 MB | Nothing | 0 | Fully static binaries |
*Approximate CVE counts from Trivy scan at time of writing. Actual counts vary.
Same Go application on each base:
# Same "hello world" Go HTTP server compiled as static binary:
# ubuntu:22.04 → 84 MB
# debian-slim → 59 MB
# alpine → 14 MB
# distroless → 9 MB
# scratch → 7 MB ← just the binary!
4. Reducing Layer Bloat
❌ Before: Bloated Dockerfile (943 MB)
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y python3 python3-pip build-essential curl wget git
RUN apt-get install -y libpq-dev
RUN pip3 install -r requirements.txt
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
CMD ["python3", "app.py"]
✅ After: Optimized Dockerfile (89 MB)
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY app.py .
ENV PATH=/root/.local/bin:$PATH
CMD ["python3", "app.py"]
Key Techniques
Combine RUN commands — each RUN creates a layer. Combine related operations:
# Bad: 3 layers, intermediate cache stays in image
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good: 1 layer, cache removed in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Skip recommended packages:
# Saves 50-200MB depending on packages
RUN apt-get install -y --no-install-recommends package-name
Use .dockerignore:
# .dockerignore
.git
node_modules
*.md
tests/
.env
__pycache__
.vscode
Remove build tools after use:
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev && \
pip install --no-cache-dir psycopg2 && \
apt-get purge -y gcc && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
5. Multi-Stage Mastery
Multi-stage builds are the single most impactful optimization. The build stage has all the tools; the final stage has only the artifact.
Go: 800MB → 12MB
# Build stage — full Go toolchain
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app
# Final stage — just the binary
FROM scratch
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
Node.js: 1.2GB → 150MB
# Build stage — dev dependencies for build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
# Final stage — only production deps + built output
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
USER node
CMD ["node", "dist/index.js"]
Rust: 2GB → 8MB
# Build stage — full Rust toolchain
FROM rust:1.77 AS builder
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl
# Final stage — static binary only
FROM scratch
COPY --from=builder /src/target/x86_64-unknown-linux-musl/release/myapp /myapp
ENTRYPOINT ["/myapp"]
Advanced: Named stages & cherry-picking
# Multiple named stages
FROM node:20 AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci && npm run build
FROM golang:1.22 AS backend-build
WORKDIR /backend
COPY backend/ .
RUN go build -o /server
# Final: combine artifacts from both stages
FROM gcr.io/distroless/static
COPY --from=backend-build /server /server
COPY --from=frontend-build /frontend/dist /static
ENTRYPOINT ["/server"]
6. Build Performance
Optimization isn't just about image size — build speed matters for developer productivity.
Maximize Layer Cache Hits
The golden rule: copy dependency manifests before source code.
# ✅ Dependencies cached until package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . . # Only this layer invalidates on code changes
# ❌ Any source change busts the npm install cache
COPY . .
RUN npm ci
BuildKit Cache Mounts
# syntax=docker/dockerfile:1
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
# Cache persists across builds — npm doesn't re-download packages
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build
Remote Cache (CI/CD)
# Push cache to registry
docker buildx build \
--cache-to type=registry,ref=registry.io/myapp:cache \
--cache-from type=registry,ref=registry.io/myapp:cache \
-t myapp:latest .
# First build on a fresh CI runner uses remote cache
# Result: npm install step → CACHED (pulled from registry)
Monorepo Strategy
# Only rebuild services whose deps changed
# Use BuildKit's --filter or Turborepo/Nx to identify affected packages
docker buildx build \
--target api-service \
--cache-from type=registry,ref=reg.io/api:cache \
.
7. Advanced: Slim & Minify Tools
DockerSlim (now Slim.AI) analyzes your running container and removes everything not used at runtime:
# Analyze and slim an image automatically
docker-slim build --target myapp:latest
# Result:
# myapp:latest → 943 MB
# myapp.slim:latest → 37 MB (96% reduction!)
# How it works:
# 1. Starts your container
# 2. Monitors syscalls (files accessed, libraries loaded)
# 3. Builds new image with ONLY those files
# 4. Adds seccomp profile for the observed syscalls
--include-path to preserve directories the probe might miss.
Industry Spotlight
Interactive Quizzes
Hands-On Tasks
🛠️ Task 1: Optimize a Bloated Dockerfile (900MB → <100MB)
Take this Dockerfile and optimize it. Target: under 100MB final image size.
# Bloated Dockerfile — your starting point
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update
RUN apt-get install -y python3 python3-pip python3-dev
RUN apt-get install -y build-essential gcc g++ make
RUN apt-get install -y curl wget git vim nano
RUN apt-get install -y libpq-dev libffi-dev libssl-dev
RUN pip3 install --upgrade pip setuptools wheel
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
# requirements.txt contains: flask, psycopg2-binary, gunicorn, requests
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Your goals:
- Use multi-stage build
- Choose a smaller base image
- Eliminate unnecessary packages
- Use
.dockerignore - Verify with
docker imagesthat the result is under 100MB
🛠️ Task 2: BuildKit Cache Mounts & Timing
Measure build performance improvement with cache mounts:
# 1. Create a Node.js project with 10+ dependencies in package.json
# 2. Write a Dockerfile WITHOUT cache mounts. Time it:
time docker build --no-cache -t myapp-nocache .
# 3. Rewrite with BuildKit cache mount:
# syntax=docker/dockerfile:1
# RUN --mount=type=cache,target=/root/.npm npm ci
# 4. Build twice (second should be fast):
time DOCKER_BUILDKIT=1 docker build -t myapp-cached .
# Change source code only:
time DOCKER_BUILDKIT=1 docker build -t myapp-cached .
# 5. Compare times. Expected: 60-80% faster rebuild.
Key Takeaways
- Measure first — use
docker historyanddiveto find where the bytes are - Choose the smallest viable base — scratch/distroless for compiled languages, slim variants for interpreted ones
- Multi-stage is non-negotiable — never ship build tools to production
- Layer order matters — dependencies before source code, always
- Cache mounts eliminate redundant downloads — BuildKit's
--mount=type=cachepersists package caches across builds - Remote cache enables fast CI — fresh runners benefit from cached layers in the registry
- Size × scale = real cost — a 10× smaller image means 10× faster autoscaling cold starts