Container Image Hardening: Distroless, Multi-Stage Builds, and Vulnerability Scanning
The average developer pulls node:latest, writes a Dockerfile, ships it, and moves on. Inside that image lives a full Debian environment: a shell, a package manager, curl, wget, compilers, and hundreds of packages that your application never touches at runtime. Every one of those packages is a potential CVE. Every installed binary is a pivot point for an attacker who achieves code execution.
Container image hardening is the practice of aggressively narrowing what ends up in your production images. The goal is deceptively simple: ship only what the application needs to run — nothing more. This post covers the full toolkit: multi-stage builds, distroless base images, Trivy scanning, Cosign signing, and the Kubernetes runtime controls that complete the picture.
Why Container Image Hardening Matters
The supply chain attack surface
A container image isn’t just your code. It’s a stack of layers, each one potentially introducing:
- Base image packages — the OS userland, system libraries, and utilities bundled into
ubuntu:22.04ornode:20 - Build tools — compilers, linkers, test frameworks, and dev headers that are useful at build time but dangerous in production
- Application dependencies — npm, pip, Maven, and Cargo packages, each with their own transitive dependency trees
- Secrets accidentally baked in —
.envfiles, SSH keys, API tokens copied into layers during development
The supply chain attack surface compounds at every layer. When SolarWinds was compromised in 2020, the attack vector was injecting malicious code into a build artifact before signing. The same class of attack applies to container images: a compromised base image, a typosquatted package, or a developer accidentally pushing an image with credentials baked in can all cascade silently into production.
Real-world impact: Log4Shell and container sprawl
When Log4Shell (CVE-2021-44228) dropped in December 2021, organizations running containers scrambled to answer one question: which of our images contains log4j? For teams with fat images — Java applications deployed on openjdk:11 with the full JDK, hundreds of Maven dependencies, and no SBOM — the answer required forensic work across every running image. Teams with lean, hardened images with software bill of materials (SBOMs) could answer the question in minutes.
Fat images also slow down your scanner. A 1.2GB openjdk:11 image with every Maven dependency baked in takes far longer to scan than a 25MB image containing only the application JAR and the JRE. The bloat isn’t just wasted disk space — it’s wasted scanning time, wasted bandwidth, and more CVEs to triage.
The cost of a fat image
| Problem | Consequence |
|---|---|
| Hundreds of extra OS packages | Larger CVE surface, more vulnerabilities to track |
Shell (/bin/sh, /bin/bash) present |
Attacker can run interactive commands post-exploitation |
Package manager present (apt, yum) |
Attacker can install additional tools |
| Curl/wget present | Attacker can exfiltrate data or download payloads |
| Build tools in prod | Potential for recompilation attacks, larger image |
| Secrets in layers | Credentials extractable from image history |
The principle maps directly to classical security: minimize attack surface by removing everything not required for the application to function.
Understanding Image Layers and Attack Surface
How Docker layers work
Every instruction in a Dockerfile creates a new layer. Layers are cached, content-addressed, and stacked. When you write:
|
|
You get five layers. The second RUN instruction alone can pull in dozens of transitive packages. Critically, even if you later run RUN apt-get purge curl in a subsequent layer, the curl binary still exists in the earlier layer and is extractable from the image.
This is the immutability problem: you can’t retroactively remove something from a lower layer. The only correct approach is to never add it in the first place.
The apt-get install trap
When you install a package in a Dockerfile, you’re not installing one thing. You’re installing a package and all of its transitive dependencies. python3-pip on Ubuntu 22.04 pulls in python3, python3.10, python3-distutils, libpython3.10, and more. A seemingly innocent RUN apt-get install -y git can add 30+ packages.
In production, you need none of that. You needed git during the build to clone a dependency. Once the build is done, git is attack surface.
Base image comparison
Choosing your base image is the highest-leverage decision you make in a Dockerfile. The difference between choices is dramatic:
| Base Image | Compressed Size | CVE Exposure | Shell | Package Manager |
|---|---|---|---|---|
ubuntu:22.04 |
~29MB (77MB unpacked) | 20-40 CVEs typically | Yes | Yes (apt) |
debian:bookworm-slim |
~31MB (75MB unpacked) | Fewer than full Debian | Yes | Yes (apt) |
alpine:3.19 |
~3.5MB (7MB unpacked) | Very few | Yes (ash) |
Yes (apk) |
gcr.io/distroless/static |
~650KB (2MB unpacked) | Near zero | No | No |
scratch |
0B | Zero | No | No |
alpine is a popular middle ground: small, has a shell for debugging, uses musl libc instead of glibc. Be aware that musl and glibc are not fully compatible — some C programs compiled against glibc will not run on Alpine. For statically compiled languages like Go, this is a non-issue.
scratch is for fully statically compiled binaries with zero runtime dependencies. A Go binary compiled with CGO_DISABLED=1 is the canonical example.
The “bloat creep” problem
Bloat often creeps in gradually. A developer adds RUN apt-get install -y vim to the Dockerfile to help debug a production issue. They fix the issue and forget the line. Six months later, you have a production image containing a full text editor with its own CVE history. Automated scanning in CI is the only reliable defense against this pattern.
Multi-Stage Builds: The Foundational Pattern
Multi-stage builds are the single most impactful change you can make to your Dockerfiles. The concept is straightforward: use one or more intermediate containers to build your application, then copy only the artifacts into a minimal final image. Build dependencies — compilers, test runners, dev headers, build scripts — never reach the production image.
Go application
Go is the ideal language for minimal containers. With CGO_DISABLED=1, Go produces a fully static binary with zero runtime dependencies. The result: a ~2MB distroless image from a ~500MB build environment.
|
|
The final image contains: your binary, SSL certificates (for HTTPS client calls), timezone data, and nothing else. No shell. No package manager. No curl. An attacker with code execution has almost nothing to work with.
The -ldflags="-w -s" strips debug symbols and the symbol table, reducing binary size. -extldflags '-static' ensures full static linking.
Node.js application
Node.js can’t produce a single static binary, so the strategy is different: separate the build dependencies (TypeScript compiler, webpack, eslint) from the runtime dependencies.
|
|
The --omit=dev flag in the production stage ensures TypeScript, webpack, jest, and all other build/test tooling are excluded. Only packages listed under dependencies (not devDependencies) are installed.
Python application
Python requires its standard library and site-packages at runtime, but not pip or setuptools.
|
|
The key trick is pip install --prefix=/install which installs packages to a separate directory that we then COPY into the final image. The gcc compiler used to build any C extensions stays in the builder stage.
gcr.io/distroless/python3-debian12 includes the Python interpreter but no pip, no setuptools, no shell, and no package manager.
Java/JVM application
Java needs the JVM at runtime but not the full JDK (compiler, javac, jlink, jshell, etc.). The JRE is roughly half the size of the JDK.
|
|
eclipse-temurin:21-jre-alpine gives you a JRE on Alpine, producing an image that’s typically 80-100MB — significantly smaller than the ~400MB build image. For even smaller Java images, consider using jlink to produce a custom JRE containing only the modules your application actually uses.
Common multi-stage mistakes
Wrong COPY –from reference: COPY --from=0 refers to the first stage by index. This is fragile — if you add a stage, the indices shift. Always use named stages: AS builder, AS tester, AS production.
Forgetting .dockerignore: Without a .dockerignore, COPY . . sends your entire project directory as build context, including .git, node_modules, .env files, and test fixtures. This bloats the build context, can bake in secrets, and defeats layer caching.
A solid .dockerignore:
.git
.gitignore
.env
.env.*
node_modules
__pycache__
*.pyc
*.pyo
.pytest_cache
.coverage
coverage/
dist/
build/
*.log
.DS_Store
Thumbs.db
*.md
tests/
docs/
.github/
Distroless Images
What distroless means
“Distroless” means the image contains no Linux distribution — no shell, no package manager, no coreutils, no apt, no yum, no apk. It contains only your application and its runtime dependencies. The term was coined by Google, which open-sourced the gcr.io/distroless image collection.
Without a shell, an attacker who achieves remote code execution in your container faces a much harder lateral movement problem. They can’t run bash, can’t use apt install to bring in new tools, can’t trivially enumerate the filesystem. They’re constrained to what your application binary can do.
Google’s distroless images
The gcr.io/distroless collection provides images for common runtimes:
| Image | Use case |
|---|---|
gcr.io/distroless/static |
Statically compiled binaries (Go, Rust) |
gcr.io/distroless/base |
Binaries requiring glibc and OpenSSL |
gcr.io/distroless/cc |
Binaries requiring glibc, libstdc++, OpenSSL |
gcr.io/distroless/java21 |
Java 21 applications (JRE only) |
gcr.io/distroless/nodejs20 |
Node.js 20 applications |
gcr.io/distroless/python3 |
Python 3 applications |
Each image has a :debug variant that adds a busybox shell, enabling interactive debugging:
|
|
Never deploy the :debug variant to production — it reintroduces the shell you removed.
Chainguard Images
Chainguard offers a commercially-backed alternative to distroless based on the Wolfi Linux distribution (a purpose-built, glibc-based OS designed for containers). Key advantages:
- Rebuilt daily — Chainguard images are rebuilt every day against the latest package versions, meaning CVEs are patched faster
- Minimal by default — every image starts from a minimal base and adds only what’s necessary
- SBOM included — every image ships with a software bill of materials
- Signed with Cosign — every image is cryptographically signed
|
|
Chainguard’s free tier covers the most popular images. Their paid tier adds older version tags (the free tier only offers :latest).
Debugging distroless containers
The lack of a shell requires a different debugging workflow. In Kubernetes, use ephemeral debug containers:
|
|
For Docker:
|
|
When distroless isn’t practical
Distroless is a poor fit for:
- Applications with shell-based entrypoints — if your
CMDis a shell script, you need a shell. Refactor to exec form (CMD ["node", "server.js"]) or keep a shell but use Alpine instead. - Applications requiring dynamic linking against system libraries not included in distroless — some native extensions, database drivers, or FFI-based libraries pull in system dependencies. Use
distroless/ccor stay on Alpine. - Heavy debugging environments — if you’re in early development and need fast iteration, use Alpine and switch to distroless for production.
Dockerfile Best Practices for Security
Run as non-root
Containers run as root by default. If an attacker exploits your application, they get root in the container. Depending on the container runtime configuration and whether the host shares the user namespace, this can translate to significant host privileges.
|
|
Use numeric UIDs in USER instructions rather than names — distroless images don’t have /etc/passwd, so name-based lookups will fail.
Pin base images to digests
Tags are mutable. golang:1.22-alpine today and golang:1.22-alpine in six weeks can point to different images if the upstream maintainers push a new build. Use digest pinning to guarantee you’re always building on the exact same base:
|
|
Automate digest updates with Renovate Bot or Dependabot, which can open PRs when new digests are available for your pinned images.
No secrets in layers
Secrets baked into image layers are permanently readable from image history, even if you delete them in a subsequent layer:
|
|
Use BuildKit secret mounts instead:
|
|
|
|
For SSH keys needed during build (e.g., private git repos):
|
|
|
|
Minimize RUN instructions
Every RUN instruction creates a layer. Chain related commands to keep intermediate state out of the final image:
|
|
Drop capabilities
Linux capabilities divide root’s privileges into distinct units. Most applications need zero capabilities. Drop them all and add back only what’s required:
|
|
|
|
No-new-privileges
Prevent privilege escalation via setuid binaries:
|
|
|
|
HEALTHCHECK instruction
Define a health check in your Dockerfile so Docker and orchestration systems can detect unhealthy containers:
|
|
Vulnerability Scanning with Trivy
Trivy is an open-source vulnerability and misconfiguration scanner from Aqua Security. It’s comprehensive, fast, and integrates cleanly into CI/CD pipelines.
What Trivy scans
- OS packages — APK, DEB, RPM packages and their CVEs from the National Vulnerability Database
- Language packages — package-lock.json, requirements.txt, go.sum, Cargo.lock, Gemfile.lock, pom.xml
- Dockerfiles — misconfigurations like running as root, missing HEALTHCHECK, unpinned tags
- Kubernetes manifests — missing resource limits, privileged containers, host namespaces
- Secrets — API keys, passwords, tokens in image layers or source files
- SBOM — can generate and scan CycloneDX and SPDX software bills of materials
Installation
|
|
Scanning images
|
|
Scanning Dockerfiles for misconfigurations
|
|
Scanning filesystems and repositories
|
|
Generating an SBOM
|
|
SBOMs are how you answer “do any of our images contain log4j?” in minutes rather than days. Storing SBOMs as artifacts alongside your container images is increasingly a compliance requirement (NIST SP 800-218, Executive Order 14028).
The .trivyignore file
Some findings are accepted risks, false positives, or simply not applicable. Document suppressions with justifications:
|
|
Signing and Verifying Images with Cosign
Cosign is part of the Sigstore project. It provides container image signing and verification, allowing you to cryptographically verify that the image you’re deploying is the exact image your CI/CD pipeline built and scanned.
Why signing matters
An image tag (myapp:v1.2.3) is a mutable pointer. It can be changed. Someone with registry write access can push a malicious image to that tag and your deployment pipeline will happily pull and run it. Signing ties the cryptographic identity of the image (its digest) to a trusted key or identity.
Key-pair signing
|
|
Keyless signing with OIDC (GitHub Actions)
Keyless signing uses the OIDC token from your CI provider as proof of identity. The private key is ephemeral — it exists only for the duration of the signing operation and is attested to by Sigstore’s Fulcio certificate authority.
|
|
Policy enforcement
Require signed images in Kubernetes using Kyverno:
|
|
Runtime Security
A hardened image is necessary but not sufficient. The container runtime and Kubernetes configuration determine what the running container can actually do.
Kubernetes securityContext
Apply these settings to every production workload:
|
|
The readOnlyRootFilesystem: true setting is particularly powerful — it means an attacker who achieves code execution can’t write new files to the filesystem (no installing tools, no writing web shells). Any application writes must go to explicitly mounted emptyDir volumes.
Kubernetes Pod Security Standards
Kubernetes 1.25+ includes built-in Pod Security Standards (replacing the deprecated PodSecurityPolicy). Apply them at the namespace level:
|
|
The restricted standard requires: non-root user, no privilege escalation, read-only root filesystem, dropped capabilities, and a seccomp profile.
Falco: runtime threat detection
Falco monitors running containers for suspicious behavior at the syscall level. Default rules detect:
- A shell spawned inside a container
- Unexpected outbound network connections
- Sensitive file reads (
/etc/shadow,/etc/passwd) - Container drift (new executables written and then executed)
- Privilege escalation attempts
|
|
CI/CD Integration: Shifting Left
The best time to catch a vulnerability is before it reaches production — ideally before it’s even pushed to the registry. Here’s a complete GitHub Actions workflow that builds, scans, signs, and conditionally pushes:
|
|
This workflow:
- Builds the image using BuildKit with layer caching
- Scans for HIGH and CRITICAL vulnerabilities — fails the build if any are found
- Uploads results to GitHub’s Security tab (visible under Security > Code scanning alerts)
- Scans the Dockerfile for misconfigurations
- Only pushes to the registry if all scans pass and the branch is
main - Signs the pushed image with keyless Cosign
- Generates and stores a CycloneDX SBOM
The ignore-unfixed: true setting is important for pipeline hygiene: it only fails on vulnerabilities where a patched version of the package is available. Unfixed CVEs are reported but don’t block the build — otherwise you’ll find yourself blocked on vulnerabilities that have no available fix and no reasonable mitigation.
Practical Hardening Checklist
Before and after: the impact of hardening
| Starting point | After hardening | Typical image size change | CVE reduction |
|---|---|---|---|
node:20 (full) |
node:20-alpine (multi-stage) |
1.1GB → ~120MB | 60-80% fewer CVEs |
node:20-alpine (multi-stage) |
Prod deps only, non-root | ~120MB → ~80MB | devDep CVEs removed |
python:3.12 |
python:3.12-slim + distroless |
~1.0GB → ~60MB | 70-85% fewer CVEs |
golang:1.22 |
distroless/static |
~800MB → ~8MB | Near-zero CVEs |
openjdk:21 (full JDK) |
eclipse-temurin:21-jre-alpine |
~700MB → ~90MB | 50% fewer CVEs |
The checklist
- Use a multi-stage build — build dependencies never reach the final image
- Use distroless or Alpine for the final stage — no shell, no package manager, minimal CVE surface
- Pin base images to digests —
FROM golang:1.22-alpine@sha256:...— never uselatest - Run as a non-root user —
USER 1001orUSER nonroot:nonroot - Create and maintain a .dockerignore — exclude
.git,.env,node_modules, test files - No secrets in image layers — use BuildKit secret mounts (
--mount=type=secret) - Scan with Trivy in CI —
--exit-code 1to fail on CRITICAL, upload SARIF to GitHub - Scan Dockerfile for misconfigurations —
trivy config ./Dockerfile - Drop all Linux capabilities —
--cap-drop ALLin Docker,capabilities: drop: [ALL]in Kubernetes - Set readOnlyRootFilesystem: true in Kubernetes securityContext
- Set allowPrivilegeEscalation: false in Kubernetes securityContext
- Apply a seccomp profile —
seccompProfile: type: RuntimeDefault - Sign the image with Cosign — keyless signing in CI, verify on deploy
- Generate and store an SBOM — CycloneDX or SPDX, stored as a CI artifact or pushed to the registry
- Define a HEALTHCHECK — in the Dockerfile, verified by your orchestrator
- Apply Kubernetes Pod Security Standards —
restrictedprofile for production namespaces
Putting It All Together
Container image hardening isn’t a single action — it’s a set of layered practices that compound. A Go service that starts as an 800MB build environment ends up as a 2MB distroless image with near-zero CVEs, no shell, no package manager, non-root execution, read-only filesystem, and a signed, SBOM-attested artifact in your registry.
The practices here aren’t exotic or difficult to implement. Multi-stage builds are a one-time refactor. Adding Trivy to CI takes 15 minutes. Switching FROM ubuntu:22.04 to FROM gcr.io/distroless/static:nonroot for a Go service is a one-line change. The security benefit is immediate and permanent.
The attackers targeting containers aren’t exploiting configuration drift in your Kubernetes seccomp profiles — they’re exploiting outdated packages in fat images pulled six months ago and never updated. Fix the basics first, scan consistently, and layer in the runtime controls as your program matures.
Start with multi-stage builds and Trivy in CI. Everything else follows naturally.
Comments