Building Minimal, Secure Container Images
How multi-stage builds, distroless base images, and layer discipline combine to produce smaller, more secure container images without sacrificing developer ergonomics.
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 flags fewer vulnerabilities (fewer packages, fewer CVEs to even be possible), and gives an attacker meaningfully less to work with even after a successful compromise. It’s a rare case in security work where the “more secure” choice and the “more efficient” choice are almost entirely the same set of changes.