Application Security
Updated 10 min read

Container Image Hardening: Distroless Builds and Minimal Base Images

200+
Packages in default debian:latest base image
90%
CVE surface reduction with distroless
0
Shells available in distroless containers
2
Stages required for clean multi-stage builds

SponsoredRetool

Retool's new app builder is where AI-generated code ships safely

Building apps with AI is easy. Getting them to production safely is another story.

Start building for free today

A container image is a Linux distribution plus your application. The Linux distribution part is usually wildly oversized for what the application actually needs. A Go binary statically compiled needs no shell, no package manager, no curl, no apt, no Python interpreter, no perl, no awk, none of the 200-plus packages that ship in debian:latest. Every one of those packages is CVE surface that gets scanned, triaged, patched, and rescanned forever, and most of them never execute. Worse, the shell and common utilities (curl, wget, busybox, base64) are exactly the tools an attacker needs to operate post-compromise. Strip the distribution down to the minimum your binary requires, and you cut both vulnerability surface and post-compromise capability in a single move.

Distroless and multi-stage builds are the mainstream approach. The idea is decades old in embedded systems and was packaged for containers by Google's distroless project, which provides a small family of base images that contain only the runtime dependencies for a specific language (Java, Python, Node.js) or no runtime at all (static, for Go and Rust). Combined with multi-stage Docker builds that keep build tools and compilers out of the runtime image, the result is an image that may be 20 MB instead of 800 MB, has no shell, and reports a handful of CVEs instead of hundreds. This guide covers the patterns, the gotchas, and how to verify your hardening worked.

Why Default Base Images Are a Security Liability

Pull debian:latest and run dpkg -l: you get 200-plus packages including apt, bash, coreutils, dpkg, e2fsprogs, findutils, grep, gzip, hostname, login, mount, ncurses, passwd, perl-base, sed, tar, util-linux, and many more. Each package is a CVE vector. The Debian security team patches them and you rebuild and redeploy, perpetually, for software you do not actually use. ubuntu:22.04 is similar. Even alpine:latest, often presented as minimal, ships with apk, busybox, ca-certificates, musl, scanelf, ssl_client, zlib, and others, and uses musl libc which has its own historical CVE pattern distinct from glibc. The package count is a proxy for attack surface; the real metric is the cross-product of installed binaries with their respective vulnerability histories. The second cost is operational: every CVE scan run by Trivy or Grype or your registry's built-in scanner produces a noisy report dominated by vulnerabilities in packages your application never invokes. Security teams spend triage cycles deciding whether a CVE in tar, which is never executed by the application, requires action. The remediation cost of running a fat base image is paid every week forever, not just at build time.

What Distroless Actually Provides

Google's distroless images at gcr.io/distroless ship a handful of variants designed around language runtimes. distroless/static is the smallest, containing only ca-certificates, /etc/passwd, /tmp, and tzdata; it is ideal for statically compiled Go and Rust binaries. distroless/base adds glibc, libssl, and openssl, supporting dynamically linked binaries. distroless/cc adds libgcc and libstdc++ for C and C++ workloads. distroless/java, distroless/python3, and distroless/nodejs ship the respective language runtime and nothing else: no shell, no package manager, no debugging tools. Every distroless image is also available in a nonroot variant that runs as UID 65532 instead of root, providing defense in depth against container escape vulnerabilities that require a privileged in-container UID. There is no apt, no apk, no sh, no bash. You cannot exec into a distroless container and explore, which is exactly the point. The CVE count for distroless/static is typically zero or one at any given time, compared to dozens for debian-slim and hundreds for full debian. Chainguard's images (cgr.dev/chainguard) and Wolfi-based images are competing alternatives that offer similar minimalism with somewhat different design choices around glibc vs musl and package manager presence in build variants.

Free daily briefing

Briefings like this, every morning before 9am.

Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.

Multi-Stage Builds: The Canonical Pattern

A multi-stage Dockerfile uses one image for building and a separate, smaller image for running. The build image has the full toolchain (compilers, package managers, source code, intermediate artifacts) and is discarded. The runtime image gets only the final artifact. For a Go binary the canonical Dockerfile reads: 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='-w -s' -o /app ./cmd/server; FROM gcr.io/distroless/static-debian12:nonroot; COPY --from=builder /app /app; USER nonroot:nonroot; ENTRYPOINT ['/app']. The result is a runtime image of roughly 20 MB containing the binary, CA certificates, and timezone data. For Java the pattern is similar with FROM eclipse-temurin:21-jdk AS builder and FROM gcr.io/distroless/java21-debian12:nonroot. For Python, the catch is that distroless/python3 ships a Python interpreter but no pip; install dependencies in a builder stage with pip install --target=/deps and COPY --from=builder /deps /app/deps in the runtime stage, then set PYTHONPATH. Always pin base images by digest (gcr.io/distroless/static-debian12@sha256:abc123...) rather than tag in production to prevent supply-chain substitution and to make scans reproducible.

Runtime Gotchas and How to Handle Them

Distroless images are minimal enough that everyday assumptions break. Timezone data: distroless/static ships tzdata at /usr/share/zoneinfo, but if you build from scratch you must COPY it from your builder stage or all time operations default to UTC, which may or may not be acceptable. CA certificates: required for any outbound HTTPS connection; distroless ships them at /etc/ssl/certs/ca-certificates.crt, but a scratch image will fail TLS handshakes silently if you forget to COPY them. Locale data: applications that rely on LC_ALL or specific locale collation for sorting or string operations will see different behavior; distroless provides only the C locale. /etc/passwd and /etc/group: distroless includes entries for nobody and nonroot but not arbitrary UIDs; if your application calls getpwuid for an unmapped UID it returns nil and may crash. DNS resolution: distroless uses glibc's NSS resolver (in distroless/base) or musl-equivalent (in alpine-derived alternatives); some Go binaries built with CGO_ENABLED=1 link glibc and behave differently from CGO_ENABLED=0 builds that use Go's pure-Go resolver. Debugging: you cannot kubectl exec into a distroless container and run anything because there is no shell. Use kubectl debug --image=busybox to attach an ephemeral debug container that shares the process namespace, or build a separate debug variant of your image with FROM gcr.io/distroless/static-debian12:debug-nonroot which includes a minimal busybox shell. Do not ship the debug variant to production.

Signing, Admission Verification, and Residual CVE Triage

Hardening the image is necessary but not sufficient. Sign the image with cosign, ideally using keyless signing backed by an OIDC identity provider in your CI environment: cosign sign --yes ghcr.io/org/app@sha256:digest. Keyless signing produces a Sigstore transparency log entry tied to the CI workload identity (GitHub Actions OIDC, GitLab CI, etc.) rather than a long-lived private key you must protect. On the cluster side, enforce signature verification at admission: cosign verify with a policy controller like Sigstore policy-controller, Kyverno, or OPA Gatekeeper that rejects pods using images not signed by your CI identity. This stops both supply-chain substitution (an attacker pushing a malicious image with a legitimate tag) and accidental deployment of unsigned images from developer laptops. Pair signing with SBOM generation (syft) and attestation (cosign attest). For residual CVE triage, run trivy image --severity HIGH,CRITICAL ghcr.io/org/app:tag as part of CI and fail the build on any unfixable HIGH or CRITICAL finding without a documented suppression. For CVEs that genuinely cannot be patched because no fixed version exists, suppress them in .trivyignore with a comment explaining why and an expiry date, and review the suppressions quarterly. The objective is that your scan output is short enough that every finding gets human review.

The bottom line

Distroless and multi-stage builds are not exotic; they are the modern default for production container images and should be the standard for any new service. The migration from fat base images is mechanical, the CVE surface reduction is dramatic, and the post-compromise hardening is real.

Pair the hardening with signing, admission verification, and aggressive CVE triage, and your container security program shifts from chasing weekly CVE reports to enforcing properties that hold by construction. That is the leverage point.

Frequently asked questions

Should I use scratch or distroless/static for Go binaries?

Distroless/static for almost all cases. It includes CA certificates, tzdata, and /etc/passwd entries for nonroot, which scratch lacks. Use scratch only if you have a specific reason to ship absolutely nothing extra and you are prepared to handle those needs yourself.

How do I debug a distroless container without a shell?

Use kubectl debug --image=busybox --target=<container> to attach an ephemeral debug container sharing the process namespace. For non-Kubernetes runtimes, build a separate debug variant of your image tagged distinctly and never deployed to production.

Does distroless eliminate all container CVEs?

No, it eliminates most CVEs from the base layer. CVEs in your application code, in language runtimes (Java JDK, Python interpreter, Node.js), and in third-party libraries you ship still appear. Distroless removes the OS-layer noise so the remaining findings are signal.

Is Alpine a reasonable middle ground between full Debian and distroless?

Alpine is smaller than Debian but still ships busybox, apk, and a shell, so it does not eliminate post-compromise capability. It also uses musl libc, which has compatibility differences from glibc that occasionally surface in Python and Java workloads. Prefer distroless or Wolfi-based images for new builds.

How do I sign distroless images and verify them at deployment?

Sign with cosign using keyless OIDC signing in CI. Enforce verification at admission with Sigstore policy-controller, Kyverno's verifyImages rule, or OPA Gatekeeper, configured to require signatures from your CI workload identity. Reject any pod referencing an unsigned image.

Sources & references

  1. Google Distroless Images
  2. Sigstore Cosign
  3. Aqua Trivy
  4. NIST SP 800-190 Application Container Security

Free resources

25
Free download

Critical CVE Reference Card 2025–2026

25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.

No spam. Unsubscribe anytime.

Free download

Ransomware Incident Response Playbook

Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.

No spam. Unsubscribe anytime.

Free newsletter

Get threat intel before your inbox does.

50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.

Unsubscribe anytime. We never sell your data.

Eric Bang
Author

Founder & Cybersecurity Evangelist, Decryption Digest

Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.

Black Hat Giveaway

Win a $2,495 Black Hat pass.

Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.

Joins Decryption Digest daily briefing. Unsubscribe anytime.

Giveaway: Black Hat USA 2026 Full-Access Pass ($2,495 value)

Details →
Daily Briefing

Subscribe to enter the giveaway

Every subscriber is automatically entered. You also get daily threat intel every morning: zero-days, ransomware, and nation-state campaigns. Free. No spam.

Already subscribed? You're already entered.

Giveaway

Win a $2,495 Black Hat USA 2026 pass.