Docker made shipping software radically simpler — and made it just as simple to ship bloated, slow, insecure containers. A 1.2 GB image that takes eight minutes to build and runs as root is not a hypothetical; it is the default outcome of a naive Dockerfile. The costs are concrete: slower CI pipelines, longer deploys and rollbacks, higher registry and bandwidth bills, slower autoscaling (every new node pulls the image before the first pod starts), and a wider attack surface.
This article is a complete, opinionated playbook for optimizing Docker containers. It is organized by goal — image size, build speed, runtime behavior, and security — because that is how you should work: pick the bottleneck that hurts, apply the targeted fix, measure again. Every technique here is standard practice in production systems; none is exotic.
Measure before you optimize
Optimization without measurement is guessing. Three commands tell you almost everything:
docker image ls # total image size
docker history <image> # size contributed by each layer
docker stats # live CPU / memory per container
For deeper analysis, dive walks through an image layer by layer and shows exactly which files each instruction added — it routinely reveals that half an image is a package manager cache or a copied .git directory. Establish the baseline, then start cutting.
Part 1 — Image size
Image size is the optimization with the broadest payoff: smaller images pull faster, deploy faster, scale faster, cost less to store, and contain fewer packages that can carry CVEs.
Choose the right base image
The base image is the single largest size decision, made in the very first line of the Dockerfile:
| Base | Typical size | Trade-off |
|---|---|---|
ubuntu:24.04 / debian:bookworm |
~75–120 MB | Familiar tooling, largest surface |
debian:bookworm-slim |
~30 MB | Debian compatibility, most tools removed |
alpine |
~5 MB | Tiny, but musl libc instead of glibc |
Distroless (gcr.io/distroless/*) |
~2–20 MB | No shell, no package manager — runtime only |
scratch |
0 MB | Static binaries only (Go, Rust) |
Two practical warnings. First, Alpine uses musl libc, not glibc — some Python wheels, Node native modules and JVM workloads behave differently or compile from source, which can make builds slower and images larger than a -slim Debian variant. Benchmark both before committing. Second, distroless and scratch images have no shell, so docker exec debugging does not work; use ephemeral debug containers (kubectl debug or a :debug tag variant) instead. That inconvenience is precisely the security benefit.
For compiled languages, the endgame is a static binary on scratch or distroless — a complete Go service image can weigh 10 MB.
Multi-stage builds: the single most important technique
Build tooling — compilers, SDKs, dev dependencies, source code — must never ship in the final image. Multi-stage builds separate the two worlds in one Dockerfile:
# --- Stage 1: build ---
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
# --- Stage 2: runtime ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
ENTRYPOINT ["/app"]
The golang:1.23 stage weighs over 800 MB; the final image weighs about 12 MB. Only the layers of the last stage ship — everything else is discarded:
flowchart LR
subgraph BUILD ["Stage 1 — build (discarded)"]
SDK["SDK / compiler<br/>~800 MB"] --> SRC["Source code"]
SRC --> DEPS["Dev dependencies"]
DEPS --> BIN["Compiled artifact"]
end
subgraph RUNTIME ["Stage 2 — runtime (shipped)"]
BASE["Minimal base<br/>~2 MB"] --> APP["Artifact only<br/>~10 MB"]
end
BIN -- "COPY --from=build" --> APP
The same pattern applies everywhere: Node (build with dev dependencies, ship dist/ plus production node_modules), Java (build with Maven/Gradle, ship the jar on a JRE image — or better, a jlink-trimmed runtime), Python (build wheels in stage one, install them in stage two).
Write a real .dockerignore
COPY . . sends the entire build context to the daemon — including .git, node_modules, build output, logs and local secrets. A .dockerignore is two minutes of work:
.git
node_modules
dist
*.log
.env*
Dockerfile
docker-compose*.yml
It shrinks the context (faster builds), prevents cache-busting from irrelevant file changes, and — critically — keeps .env files and credentials out of image layers.
Clean up inside the same layer
Each RUN instruction creates an immutable layer. Deleting files in a later instruction does not reclaim space — the files still exist in the earlier layer. Install and clean in one instruction:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
The same rule for other ecosystems: apk add --no-cache (Alpine), pip install --no-cache-dir, npm ci --omit=dev followed by npm cache clean --force. And --no-install-recommends alone regularly saves tens of megabytes of packages you never asked for.
Part 2 — Build speed and layer caching
Docker caches each layer and reuses it as long as the instruction and its inputs are unchanged. The catch: the first modified layer invalidates every layer after it. Order your Dockerfile from least-changing to most-changing, or you rebuild the world on every commit:
flowchart TB
subgraph BAD ["Wrong order — code change rebuilds everything"]
B1["FROM node:22-slim"] --> B2["COPY . ."]
B2 --> B3["RUN npm ci<br/>(re-runs on EVERY code change)"]
end
subgraph GOOD ["Right order — dependencies stay cached"]
G1["FROM node:22-slim"] --> G2["COPY package*.json ./"]
G2 --> G3["RUN npm ci<br/>(cached until deps change)"]
G3 --> G4["COPY . .<br/>(only this re-runs)"]
end
In the wrong version, every code change re-runs dependency installation — often the slowest step in the build. In the right version, npm ci re-runs only when package.json or the lockfile actually change. This one reordering is frequently the difference between a 30-second and a 6-minute CI build.
Use BuildKit — and its cache mounts
BuildKit (the default builder in modern Docker) builds independent stages in parallel, skips stages the target doesn’t need, and adds two powerful mount types:
# Persistent package cache across builds — survives layer invalidation
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Secrets available during the build, never written into any layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
A cache mount keeps the package manager’s download cache on the build host between builds, so even when the dependency layer is invalidated, packages are not re-downloaded. The secret mount solves an old and dangerous anti-pattern: passing credentials via ARG or ENV, which bakes them permanently into image history.
Cache in CI, not just locally
CI runners are usually ephemeral — without configuration, every build starts cold. Export the cache to the registry or the CI cache backend:
docker buildx build \
--cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
-t ghcr.io/acme/app:$SHA .
With mode=max, intermediate layers of all stages are cached too, so multi-stage builds get warm caches in CI. On GitHub Actions, type=gha uses the built-in cache service directly.
Pin your bases for reproducibility
FROM python:3.12 is a moving target — it can silently change between builds and break caching or, worse, behavior. Pin at least the minor tag, and for production images pin the digest:
FROM python:3.12-slim@sha256:af4e85f1cac90dd3771e47292ea7c8a9830abfabbe4faa5c53f158854c2e819d
Digest pinning makes builds byte-reproducible and immune to tag hijacking; a bot (Renovate, Dependabot) keeps the digest fresh.
Part 3 — Runtime optimization
A small image is only half the job; the container must also behave well under real load.
Always set resource limits
An unbounded container can consume the entire host and starve its neighbors — one memory leak takes down every service on the node:
services:
api:
image: ghcr.io/acme/app:1.4.2
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
memory: 256M
Sizing rule of thumb: measure real usage with docker stats under production-like load, then set the memory limit ~30–50% above steady state. Too tight and the kernel OOM-kills the container in production; absent and you have no isolation at all.
Make the runtime container-aware
The subtler trap: many language runtimes size themselves from what they see, and what they see must be the container’s limits, not the host’s. Modern JVMs (11+) are container-aware, but the defaults still deserve tuning:
# JVM: use a percentage of the container limit, not a fixed -Xmx
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"
# Go: let the runtime respect the cgroup memory limit
GOMEMLIMIT=400MiB
# Node: heap ceiling below the container limit
NODE_OPTIONS="--max-old-space-size=384"
The recurring failure mode is a JVM or Node process sized for a 64 GB host inside a 512 MB container — it works in staging and OOMs at the first traffic spike.
Rotate your logs
The default json-file logging driver grows without bound and is a classic cause of “the disk is full and nothing obvious is using it”:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Set it once in /etc/docker/daemon.json (or per service in compose). If logs go to a central system anyway, local is a more efficient driver than json-file.
Health checks and graceful shutdown
An optimized container is also one the orchestrator can manage correctly. Two things make that possible.
A meaningful health check — one that verifies the service actually serves, not merely that the process exists:
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD ["/app", "healthcheck"]
--start-period matters: without it, a slow-starting service gets marked unhealthy and restarted in a loop before it ever finishes booting.
Correct signal handling. Use the exec form of ENTRYPOINT (["/app"], not /app as a shell string) — the shell form wraps your process in /bin/sh, which swallows SIGTERM, so every stop waits for the 10-second timeout and ends in SIGKILL, dropping in-flight requests. If your process spawns children or can’t reap zombies, add init: true (compose) or docker run --init to get a minimal PID 1. Then handle SIGTERM in the application: stop accepting connections, drain, exit.
Use tmpfs and volumes deliberately
The container’s writable layer goes through the storage driver (overlay2) — fine for light writes, wrong for heavy I/O. Put scratch data in memory and hot data on volumes:
services:
api:
tmpfs:
- /tmp:size=64m
volumes:
- dbdata:/var/lib/postgresql/data # named volume: native FS performance
Databases, caches and anything write-heavy belong on volumes; ephemeral scratch space belongs on tmpfs; and a read-only root filesystem (next section) makes the distinction explicit and enforced.
Part 4 — Security is optimization
Every hardening step below also shrinks the image, the privileges, or the blast radius — security and optimization are the same discipline here.
Run as non-root. The default user in a container is root; a container escape then starts with root on the shared kernel. Create a user, or use a base variant that has one:
FROM node:22-slim
RUN useradd --system --uid 10001 appuser
USER appuser
Distroless images ship a :nonroot variant; use it.
Drop what you don’t use. Most services need no Linux capabilities at all, no privilege escalation, and no writable root filesystem:
services:
api:
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
If the service later genuinely needs a capability (binding a port below 1024, for instance, needs NET_BIND_SERVICE), add back exactly that one.
Scan continuously. docker scout cves <image> or trivy image <image> in CI, with a fail threshold on critical vulnerabilities. Scanning pairs directly with the size work: a distroless image has dramatically fewer packages, so scan reports go from hundreds of findings to a handful you can actually read.
Never bake secrets into layers. No credentials in ARG, ENV, or COPY-ed files — image history is readable by anyone who can pull the image. Build-time secrets use BuildKit secret mounts (shown above); runtime secrets come from the orchestrator’s secret store or mounted files, not the image.
One process, one container. Not dogma — pragmatism. One process per container keeps images minimal, restart semantics clean, health checks meaningful, and scaling independent. The moment you reach for supervisord inside a container, you usually want two containers instead.
The checklist
For a Dockerfile review or a new service, this is the condensed version:
Image
- Smallest viable base (
-slim, alpine, distroless,scratch) — benchmarked, not assumed - Multi-stage build; no compiler, SDK or dev dependencies in the final stage
.dockerignoreexcludes.git, dependencies, build output,.env- Package caches cleaned in the same
RUNlayer;--no-install-recommends - Base image pinned (digest in production) and auto-updated by a bot
Build
- Layers ordered least- to most-frequently changing; dependency files copied before source
- BuildKit cache mounts for package managers; secret mounts for build credentials
- Registry or CI cache configured (
--cache-from/--cache-to)
Runtime
- CPU and memory limits set from measured usage
- Runtime flags container-aware (
MaxRAMPercentage,GOMEMLIMIT,--max-old-space-size) - Log rotation configured
HEALTHCHECKwith--start-period; exec-formENTRYPOINT;SIGTERMhandled;--initwhen neededtmpfsfor scratch data, volumes for write-heavy paths
Security
- Non-root
USER read_only: true,cap_drop: ALL,no-new-privileges- Image scanning in CI with a fail threshold
- No secrets in layers, ever
Closing thoughts
None of these techniques is difficult in isolation; the leverage comes from applying them systematically. A realistic before/after for a typical service: image size from 1.2 GB to under 100 MB, cold CI build from eight minutes to under one with warm caches, deterministic deploys from digest pinning, and a container that an orchestrator can actually schedule, health-check and stop cleanly.
Start with the two highest-return moves — a multi-stage build and correct layer ordering — measure, and work down the checklist. Your registry bill, your CI queue and your on-call rotation will all notice.