Trim Fat, Ship Faster: docker Images 38% Smaller
Practical patterns for lean builds, sane security, and happy deploys.
Start With the Runtime You Actually Need
Most of us start with whatever base image is one tab away. Then we wonder why our containers are 1.2 GB and take an espresso to boot. Let’s be choosier. The runtime you pick dictates size, attack surface, and how often you’ll be patching. Alpine is tiny, but musl can trip up certain libraries at runtime. Debian/Ubuntu “slim” variants stay small-ish while keeping glibc. Distroless images strip shells and package managers entirely; they’re great for compiled apps that don’t need interactive tooling. If you’re running a Go or Rust binary, “distroless:static” or even “scratch” is hard to beat.
Pin your base image by digest, not just tag. That keeps your builds repeatable, and it’s aligned with the OCI Image Spec that underpins layers and digests. We also like to align the base image OS with the libraries our app expects. If you built your wheel/deb against glibc on Debian, running it on Alpine can be “exciting” in the bad way.
One more thing we forget: architecture. If your fleet is mixed AMD64 and ARM64, choose multi-arch images or build per-arch. Cross-building is easy these days, but runtime CPU mismatches aren’t. For many teams, we’ve seen a straightforward swap—Debian slim to distroless for a Go service, pinned by digest—cut image size by 30–50% and startup latency by a couple of seconds. The payoff isn’t just disk and bandwidth. Fewer packages means fewer CVEs to chase, fewer surprises at runtime, and far less toil gluing tools into a container that should just do one job well.
Make Multi-Stage Builds Do Real Work
Multi-stage builds aren’t a checkbox; they’re where we keep the mess in one stage and ship only the essentials. The builder stage gets compilers, dev headers, and test tools. The final stage carries the minimum you need to run. For compiled languages, this is a slam dunk. Here’s a tidy Go example that compiles statically and runs as non-root:
# Builder
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app ./cmd/app
# Runtime
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
This pattern knocks out a few problems. Our build tools never make it into production. We don’t leak unused test binaries. We run as a non-root user without a shell, which reduces the “oops I exec’d into prod and installed curl” moments. And we get a smaller image that pulls faster and starts quicker.
Java and Node can benefit too. Build your fat JAR or frontend bundle in stage one, then copy only the jar/bundle plus runtime (JRE, node) into stage two. Even in interpreted languages, multi-stage helps by keeping the final layer stack clean and predictable. Our litmus test: if you can’t explain what’s in the runtime image in one sentence, your multi-stage build isn’t strict enough yet.
Caching Like We Mean It: Layers, Args, and BuildKit
We’ve all written Dockerfiles that rebuild the world because a single line changed. Let’s stop paying that tax. Order your layers from least to most volatile. Copy manifests first, install dependencies, then copy source. That way, package installs are cached when only your app code changes. Also embrace BuildKit—it’s on by default now and brings powerful cache mounts.
Two tricks pull their weight every day. First, cache package managers:
# Node packages with cache
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
Second, isolate dependency steps from source changes:
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
If you’re on apt or pip, same idea—cache /var/cache/apt
or ~/.cache/pip
. The syntax and options are documented in the Dockerfile reference, including details on cache sharing and permissions.
Add a proper .dockerignore
too. Sending your entire git history, screenshots, and node_modules to the daemon is a quiet performance killer. Finally, use ARG
thoughtfully to control cache busting. ARG BUILD_DATE
used wrong can nuke your cache; ARG APP_VERSION
threaded into LABEL
or build-time flags keeps you honest without ruinous rebuilds. These small habits stack up to minutes saved per build and a CI queue that doesn’t end at lunchtime.
Keep Secrets Secret Without Going Grey
Where secrets go in containers is where stress goes in our lives. Step one: never bake secrets into images. A secret in a layer is forever—or at least until a CVE scanner emails you at 5 a.m. Use BuildKit’s secret mounts for private dependency installs and keep them out of the final image. Example for Node:
# Dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
Build with:
docker build --secret id=npmrc,src=$HOME/.npmrc .
The file exists only during that RUN step and doesn’t land in any layer. This trick works for pip
, go env GOPRIVATE
, or private Git access too (--mount=type=ssh
for SSH keys).
At runtime, prefer file-based secrets over environment variables when you can. Env vars leak into crash dumps, metrics, and “just one quick debug log.” Mount a secret file and point the app via config—most libraries support reading from a path. Rotate credentials externally and consider short-lived tokens so an accidentally logged value ages out quickly.
Finally, keep your registry settings boring and explicit: least-privilege repository access, separate read/write identities for CI and runtime, and image signing if your workflow supports it. We’d rather be predictable than clever when keys are involved.
Trim Attack Surface Without Breaking Your App
“Secure” doesn’t mean “it won’t start.” We can tighten containers without booby-trapping ourselves. First, stop running as root. Use a non-root user in your Dockerfile or runtime flags. Then cut Linux capabilities: most web apps don’t need to change the system clock or load kernel modules. Go read-only when possible, and mount just the writable directories your app needs (e.g., /tmp
or a data dir). A quick runtime example:
docker run --read-only \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges \
--pids-limit=200 --memory=256m --cpus=0.5 \
-v app-tmp:/tmp \
your/image:tag
The defaults matter too. The Docker default seccomp profile is a good baseline; avoid disabling it unless you know exactly why. If you’re unsure whether a tightening step is reasonable, the CIS Docker Benchmark is a pragmatic checklist for host, daemon, and container settings. We don’t have to do everything it says tomorrow, but it’s a useful compass.
Keep the image lean: fewer packages, fewer shells, fewer setuid binaries. Use HEALTHCHECK
in the image or healthchecks in your orchestrator, so broken apps stop receiving traffic quickly. And resist the temptation to layer “just for debug” tools in production images. If we need them, we can attach ephemeral debug containers. Keeping prod images boring and minimal is the real superpower.
Compose and Health-Check Like a Pro
Compose is where many teams run real workloads, even if no one admits it in meetings. Let’s make it stable. Add healthchecks, resource limits, sane restarts, and keep networks tidy. Here’s a small but mighty example:
version: "3.9"
services:
api:
image: your/image:prod
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
mem_limit: 512m
cpus: "1.0"
environment:
- APP_ENV=production
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 2s
retries: 3
start_period: 10s
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- api-tmp:/tmp
networks:
default:
name: app-net
volumes:
api-tmp:
A few notes. mem_limit
and cpus
give basic guardrails on a single host—enough to protect neighbors. read_only
catches accidental writes and insists we think about where files go. Healthchecks keep the restart policy honest; if your app craters, it gets a quick reboot instead of limping along in mystery mode.
If you need secrets, prefer file mounts over env vars, and don’t check them into compose files—use .env
files or external secret stores. Finally, split services into separate networks if they don’t need to chat. Default “everyone can talk to everyone” sounds friendly until a noisy dependency takes down the stack.
Production Deploys That Don’t Surprise Us at 3 A.M.
Kubernetes isn’t a silver bullet, but it does ship good patterns by default. We can make containers safer and more predictable with a little YAML discipline: probes, resource requests/limits, non-root users, capability drops, and a seccomp profile. Here’s a trimmed Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector: { matchLabels: { app: api } }
template:
metadata: { labels: { app: api } }
spec:
securityContext:
seccompProfile: { type: RuntimeDefault }
containers:
- name: api
image: your.registry/api@sha256:deadbeef
imagePullPolicy: IfNotPresent
ports: [{ containerPort: 8080 }]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"], add: ["NET_BIND_SERVICE"] }
resources:
requests: { cpu: "200m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 10
periodSeconds: 10
Probes route traffic only to ready pods and kill stuck ones. The official docs cover probe nuances well—timeouts, thresholds, and pitfalls are worth a read: Kubernetes probe configuration. Pin images by digest to make rollouts repeatable. Use RollingUpdate
defaults and keep maxUnavailable
sensible so you don’t go dark during a deploy. And log structured events so you can answer “did the pod restart?” before your second coffee. The sum of these settings isn’t glamorous, but it’s how we avoid 3 a.m. surprises.
Where We Go From Here
docker shines when we’re disciplined about inputs and boring about outputs. Choose the smallest runtime that still fits, then make multi-stage builds prove their worth by leaving tools behind. Let BuildKit cache what’s slow and messy, and keep secrets out of layers entirely. Tighten runtime privileges until your app complains—then give it only what it needs and nothing more. Add healthchecks and limits so failures are crisp, not mushy. And when it’s time to go to production, let YAML do what it’s good at: declaring dull, predictable behavior.
We’ve seen teams shave 38% off image sizes just by switching base images and finishing their multi-stage builds properly. Build times drop from minutes to under a minute when caches are dialed in. On-call pages shrink when probes and read-only filesystems catch issues early. None of this is wizardry; it’s consistency. Let’s pick a couple of the patterns above, bake them into templates, and hold the line in code reviews. The best docker setups aren’t loud—they’re the ones we forget about because everything just works, and we finally get to finish that coffee while it’s still warm.