Building Minimal, Secure Container Images
How multi-stage builds, distroless bases, secret-safe caching, non-root execution, and layer inspection produce smaller, auditable runtime images.
A container image built without deliberate attention to size and contents tends to accumulate build tools, package caches, and an entire OS userland your application never actually needs at runtime — every one of those is both wasted space and additional attack surface. Building minimal images is a well-established set of practices, not a single trick.
The problem with a naive single-stage build
A straightforward Dockerfile that installs build dependencies, compiles the application, and ships the result all in one stage bakes the compiler, build caches, and dev headers into the final image permanently:
# Naive: ships the entire build toolchain in the runtime image
FROM golang:1.22
COPY . .
RUN go build -o app .
CMD ["./app"]
That image might be several hundred megabytes for what’s ultimately a single small binary — every megabyte of it is also more package surface a vulnerability scanner will flag and more code an attacker who gains a foothold has available to abuse.
Multi-stage builds: separating build-time from run-time
A multi-stage build uses one stage to compile, and a separate, much smaller final stage that only copies the finished artifact across:
# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .
# Stage 2: minimal runtime
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
The golang:1.22 build stage — compiler, module cache, everything — never ends up in the final image at all; only the COPY --from=builder artifact does. This single pattern is responsible for the largest size reductions most teams see.
Distroless and scratch: how minimal can you go
Distroless images ship just a language runtime (or nothing at all, for a statically-linked binary) with no shell, no package manager, and no general-purpose userland utilities — deliberately removing the tools an attacker would use to explore a compromised container (sh, curl, apt) rather than just removing unused packages:
FROM scratch
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
scratch is the literal empty base image — viable only for fully static binaries with zero runtime dependencies (no dynamic linker, no CA certificates, no timezone data), which is why distroless (a very thin but non-empty base including things like CA certs) is more commonly the practical choice than bare scratch.
Layer caching: ordering Dockerfile instructions deliberately
Docker caches each layer, invalidating everything from the first changed instruction onward — which is why dependency installation should happen before copying application source code, not after: source code changes on every commit, dependency manifests change rarely.
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Reordering this so COPY . . happened before npm ci would invalidate the dependency-install cache on every single source change — a common, easily-fixed source of unnecessarily slow CI builds.
Squashing and minimizing layer count
Each RUN instruction adds a layer, and intermediate files created and deleted across separate RUN instructions still bloat the image, since earlier layers are immutable — chaining related operations into a single RUN avoids leaving that intermediate state behind permanently:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Doing the apt-get update/install/cleanup as three separate RUN layers would leave the downloaded package cache baked into an earlier layer even after a later layer deletes it — layers are additive, not a net diff, which is the detail that makes single-command chaining meaningfully different from “the same commands, just split across more lines.”
Running as non-root: a minimal image is still exploitable if privileged
Image minimalism reduces attack surface but doesn’t replace running as an unprivileged user — a distroless image that still runs as root inside the container gives an attacker who achieves code execution full root within that container’s namespace regardless of how few binaries are present:
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder --chown=nonroot:nonroot /app /app
USER nonroot
ENTRYPOINT ["/app"]
Measuring the actual impact
docker history and dive (a dedicated layer-inspection tool) make the effect of these choices concrete rather than theoretical — visualizing exactly which layer contributed how many megabytes, which is the fastest way to find an unexpectedly large layer that a multi-stage build or better .dockerignore would eliminate:
docker history myapp:latest
dive myapp:latest
Why this discipline compounds
None of these techniques individually is complicated, but applied together — multi-stage builds, a minimal or distroless final base, deliberate layer ordering, non-root execution — they compound: a smaller image pulls faster (meaningful at Kubernetes rollout scale across many nodes), scans faster and usually exposes fewer packaged components, and gives an attacker less tooling to abuse after a compromise. Minimalism reduces surface; it does not prove the remaining code is safe.
Reproducible inputs and secret-safe builds
Pin base images by digest in controlled release builds and automate detection of approved updates. A floating base tag can change bytes without a Dockerfile change; a permanent digest pin can miss security updates unless a rebuild process advances it. Record builder version, frontend, target platform, source revision, dependency locks, build arguments that are safe to disclose, and resulting image digest.
Use BuildKit secret and SSH mounts or the builder’s equivalent for credentials needed during a step. Do not place secrets in ARG, ENV, copied files, or URLs that persist in history or cache metadata. Keep the build context narrow with .dockerignore, and inspect the final image and history for unexpected files. CI should build from a clean checkout under a short-lived identity.
Runtime completeness must be tested
Minimal images can omit CA certificates, timezone data, locale data, dynamic libraries, users, or writable directories an application assumes exist. Enumerate runtime dependencies and test TLS, DNS, time zones, signal handling, temporary files, health checks, and shutdown in the final stage—not in the builder. Run as a numeric non-root user when the platform requires it and set ownership during COPY instead of performing a broad recursive change later.
No shell complicates emergency exploration by design. Build observability into the application and use platform-supported ephemeral debug containers or a separate, controlled debug image rather than shipping troubleshooting tools in production. A debug image must match the target architecture and network assumptions and should not become an unreviewed path to production.
Layer and platform verification
Use docker image inspect, docker history, registry manifest inspection, and an SBOM to understand the result. Multi-stage builds do not automatically exclude everything: copying a directory can include caches, test data, private keys, or dynamically linked artifacts whose libraries are absent. Scan the final digest and verify its entrypoint, user, environment, exposed metadata, layers, and platform manifests.
For multi-architecture releases, build and test each platform-specific image before publishing the index. Do not assume a successful x86_64 build validates ARM64. Retain provenance and SBOM subjects for both index and selected manifest where tooling supports it.
Operational acceptance
Measure compressed and unpacked size, pull and startup latency, vulnerability and package inventory, memory at startup, and rollback behavior. Set budgets that reflect the application rather than chasing the smallest possible number. Removing a required diagnostic certificate bundle to save a few megabytes is a reliability regression.
Rebuild regularly even when application source is unchanged, because base packages and vulnerability knowledge evolve. Promote the same tested digest through environments, retain rollback digests, and exercise restoration after registry cleanup. Combine image minimalism with read-only root filesystems where compatible, dropped capabilities, seccomp, network controls, and least-privilege identities.
Related:
- Fixing ‘No Space Left on Device’ from Docker Image and Container Buildup
- Solomon Hykes Demos Docker Publicly for the First Time
Sources: