Container Image Security Hardening: Dockerfile Best Practices
Container images are the unit of deployment in modern infrastructure. They are also the unit of vulnerability distribution: every CVE baked into your base image ships to every container runtime in every environment. This guide covers the full container image security lifecycle -- from writing a hardened Dockerfile to selecting minimal base images, scanning for vulnerabilities, generating SBOMs, enforcing image signing, and controlling what reaches your production registries.
Dockerfile Hardening: The Foundation
Every production Dockerfile should implement these controls. They address the most common container escape and privilege escalation patterns.
1. Use a specific, pinned base image tag
# Bad: pulls whatever 'latest' is at build time
FROM ubuntu:latest
# Good: pinned to a specific digest (immutable)
FROM ubuntu:22.04@sha256:a6d2b38300ce017add71440577d5b0a90460d0e57fd7aec21dd0d1b0761bbfb2
Pinning by digest (not tag) guarantees the exact image layer is used at every build. Tags are mutable -- an upstream maintainer can update ubuntu:22.04 without changing the tag.
2. Run as a non-root user
# Create a dedicated app user and group
RUN groupadd -r appgroup && useradd -r -g appgroup -s /sbin/nologin appuser
# Switch to non-root before the ENTRYPOINT
USER appuser
ENTRYPOINT ["/app/server"]
Running as root inside the container means that any container escape puts the attacker as root on the host (if the runtime is misconfigured) or allows the attacker to write to sensitive paths inside the container. There is almost never a legitimate reason for an application to run as root.
3. Use a multi-stage build to minimize the final image
# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o server .
# Stage 2: Minimal runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/server /server
ENTRYPOINT ["/server"]
Multi-stage builds produce a final image that contains only the compiled binary (and any runtime dependencies), not the build toolchain. The distroless image used here has no shell, no package manager, and no OS utilities -- an attacker who achieves code execution inside this container has almost nothing to work with.
4. Do not copy secrets or credentials into the image
# Bad: credentials in image layers
COPY .env /app/.env
ENV DATABASE_PASSWORD=secret123
# Good: pass credentials at runtime via environment variables or secrets management
# Docker: --env-file or --secret flag
# Kubernetes: Secrets mounted as volumes or environment variables from secretKeyRef
Every layer in a Docker image is stored in the registry and accessible to anyone with pull access. Credentials embedded in any layer (even if RUN rm .env is called in a subsequent layer) remain in the layer history and can be extracted with docker history.
5. Set a read-only filesystem
In Kubernetes pod spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
A read-only filesystem prevents an attacker who achieves code execution from writing backdoors, modifying binaries, or creating reverse shell scripts inside the container. If your application needs to write to disk, mount a specific writable volume only for that path.
6. Drop all Linux capabilities
Containers inherit a default set of Linux capabilities. Drop them all and add back only what is needed:
# Most web applications need zero capabilities
securityContext:
capabilities:
drop: ["ALL"]
add: [] # add back specific caps only if required: NET_BIND_SERVICE for port <1024
Base Image Selection: Distroless, Minimal, and Scratch
The base image choice has a larger impact on vulnerability count than almost any other single decision.
Base image comparison:
| Base Image | CVE Count | Shell | Package Manager | Use Case |
|---|---|---|---|---|
| ubuntu:22.04 | 40-80 CVEs typical | Yes (bash) | Yes (apt) | Development/debugging |
| debian:slim | 20-40 CVEs typical | Yes | Yes (apt) | General purpose |
| alpine:3.19 | 5-15 CVEs typical | Yes (sh) | Yes (apk) | Minimal Linux needed |
| distroless/static | 0-5 CVEs typical | No | No | Static binaries (Go, Rust) |
| distroless/base | 5-10 CVEs typical | No | No | glibc-dependent binaries |
| scratch | 0 CVEs | No | No | Fully static binaries only |
Google Distroless images (gcr.io/distroless):
Distroless images contain only the language runtime and its dependencies. No shell, no utilities, no package manager. Options:
distroless/static-debian12-- for fully static binaries (Go with CGO_ENABLED=0, Rust)distroless/base-debian12-- for binaries that need glibcdistroless/java21-debian12-- for Java applicationsdistroless/python3-debian12-- for Python applications
Each has a :nonroot variant that sets the default user to a non-root UID.
Alpine Linux trade-offs:
Alpine uses musl libc instead of glibc. Some applications compiled against glibc will not run correctly on Alpine without recompilation. Alpine's package repository has a smaller CVE surface than Debian/Ubuntu, but the musl vs. glibc difference can introduce subtle bugs in applications that assume glibc behavior.
When to use scratch:
The scratch base is a completely empty image -- only your binary. It works only for statically compiled binaries (Go with CGO_ENABLED=0, Rust). No TLS certificates are included, so you must copy the CA bundle explicitly:
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /build/server /server
ENTRYPOINT ["/server"]
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Image Scanning: Trivy, Grype, and Shifting Left
Image scanning identifies known CVEs in the OS packages and application dependencies within your container image.
Trivy (Aqua Security):
Trivy is the most widely used open-source container scanner. It scans:
- OS packages (Alpine apk, Debian dpkg, RHEL rpm)
- Application dependencies (npm, pip, Maven, Gradle, Go modules, Cargo)
- Kubernetes manifests and Helm charts
- Infrastructure as code (Terraform, CloudFormation)
- Secret detection (exposed credentials, API keys)
# Scan a local image
trivy image myapp:latest
# Scan with SARIF output for IDE/PR integration
trivy image --format sarif --output results.sarif myapp:latest
# Fail CI on critical CVEs
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Scan a Dockerfile before building
trivy config ./Dockerfile
# Generate SBOM
trivy image --format spdx-json --output sbom.spdx.json myapp:latest
Grype (Anchore):
Grype is an alternative scanner with strong CI/CD integration:
# Scan an image
grype myapp:latest
# Fail on high/critical
grype myapp:latest --fail-on high
# Scan a directory (for application dependencies without building)
grype dir:./src
Shifting scanning left:
| Stage | Tool | Action |
|---|---|---|
| IDE / pre-commit | Trivy or Snyk plugin | Scan Dockerfile and dependencies before committing |
| Pull request | Trivy GitHub Action | Block PR merge if new CVEs are introduced |
| CI build | Trivy or Grype | Fail build on critical CVEs |
| Registry push | Container registry scanning | Scan on push (ECR, GAR, ACR all offer native scanning) |
| Runtime | Falco, Sysdig, Aqua | Detect anomalous behavior from containers in production |
Managing false positives: Not every CVE in a scan is exploitable in your environment. Trivy supports a .trivyignore file for documenting accepted risks:
# .trivyignore
CVE-2023-12345 # Not exploitable: this path is not exposed to untrusted input
SBOM Generation and Software Supply Chain Security
A Software Bill of Materials (SBOM) is a machine-readable inventory of every component in your container image. It enables rapid response to new CVEs by answering "which of our images are affected?"
SBOM standards:
- SPDX (Software Package Data Exchange) -- Linux Foundation standard, JSON/XML/TV formats
- CycloneDX -- OWASP standard, JSON/XML, tooling-friendly
- Syft -- Anchore's SBOM generator, supports both SPDX and CycloneDX output
Generating SBOMs with Syft:
# Generate CycloneDX SBOM from an image
syft myapp:latest -o cyclonedx-json > sbom-cyclonedx.json
# Generate SPDX SBOM
syft myapp:latest -o spdx-json > sbom-spdx.json
# Scan an SBOM for vulnerabilities with Grype
grype sbom:./sbom-cyclonedx.json
Image signing with Cosign (Sigstore):
Cosign signs container images using keyless signing (OIDC-based) or key-based signing. Unsigned images should be rejected at the Kubernetes admission controller level.
# Install cosign
brew install cosign # macOS
# Sign an image (keyless via OIDC)
cosign sign myregistry.io/myapp:v1.2.3
# Verify a signature
cosign verify myregistry.io/myapp:v1.2.3 --certificate-identity=https://github.com/myorg/myapp/.github/workflows/build.yml@refs/heads/main --certificate-oidc-issuer=https://token.actions.githubusercontent.com
Enforcing image policies in Kubernetes:
Use Kyverno or OPA/Gatekeeper to enforce image signing, registry allowlists, and scan results:
# Kyverno policy: require signed images
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
rules:
- name: check-image-signature
match:
resources:
kinds: ["Pod"]
verifyImages:
- image: "myregistry.io/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/myorg/*"
issuer: "https://token.actions.githubusercontent.com"
Registry Security and Image Lifecycle Management
Your container registry is the distribution point for every image in your environment. Its security posture directly affects every workload you run.
Registry access controls:
- Enable image signing verification at the registry level (ECR's enhanced scanning, GCR's Binary Authorization)
- Implement pull-through cache with scanning for public registries -- never pull untrusted images directly from Docker Hub in production
- Restrict push permissions to CI/CD service accounts only; developers should not push directly to production registries
- Enable audit logging for all push and pull events
Image retention and tag hygiene:
# Delete images older than 90 days in AWS ECR
aws ecr list-images --repository-name myapp --filter tagStatus=UNTAGGED --query 'imageIds[*]' --output json | aws ecr batch-delete-image --repository-name myapp --image-ids file:///dev/stdin
Establish a retention policy: keep the 10 most recent tagged versions, delete all untagged images older than 7 days.
Private registry for base images:
Instead of pulling from Docker Hub directly (subject to rate limits and potentially malicious image substitution), mirror approved base images into your private registry and update them on a schedule:
# Mirror ubuntu:22.04 to your private registry
docker pull ubuntu:22.04
docker tag ubuntu:22.04 myregistry.io/base/ubuntu:22.04
docker push myregistry.io/base/ubuntu:22.04
Enforce via Kubernetes admission controller: reject any pod spec that references docker.io/* or *:latest.
Base image update automation:
Use Renovate or Dependabot to automatically create pull requests when new base image versions are released. Configure Trivy scanning in CI to ensure the PR does not introduce new critical CVEs before merging.
The bottom line
Container image security is a shift-left problem. By the time a vulnerable image reaches production, the vulnerability has already been distributed to your registry, your staging environment, and potentially your CI artifacts. The controls that make the biggest difference are: pinning base image digests, switching to distroless or minimal base images, scanning in CI with a build-breaking threshold, and enforcing image signing at the Kubernetes admission controller. These four controls address the majority of container supply chain risk with relatively low operational overhead.
Frequently asked questions
What is a distroless container image and why should I use it?
Distroless images (from Google's gcr.io/distroless project) contain only the application runtime and its dependencies -- no shell, no package manager, no OS utilities. For an attacker who achieves code execution inside the container, a distroless image provides no tools to work with: no bash to run commands, no curl to download payloads, no apt to install tools. Distroless images also have significantly fewer CVEs because they exclude the large body of OS packages that are included in general-purpose images but not needed by the application. The trade-off is debugging complexity: you cannot exec into the container and run commands interactively. Use ephemeral debug containers (kubectl debug) for troubleshooting instead.
Is scanning container images in CI sufficient for supply chain security?
CI scanning is necessary but not sufficient. It covers known CVEs in the packages you build into your image at build time. It does not cover: (1) new CVEs discovered after your image is built and deployed; (2) malicious code injected into base images between your CI run and your next pull; (3) vulnerabilities in build-time tools that do not end up in the final image but can compromise the build pipeline. A complete supply chain security posture adds: runtime scanning (Falco/Sysdig) to detect exploitation of unknown vulnerabilities, digest pinning to prevent base image tampering, image signing to ensure the image that reaches production is the one your CI built, and SBOM generation to enable rapid impact assessment when new CVEs are disclosed.
How do we handle secrets that an application needs at container startup?
Never bake secrets into the image. Three production-grade approaches: (1) Kubernetes Secrets mounted as environment variables or volumes -- the secret is stored in etcd (encrypt etcd at rest) and injected at pod startup; (2) External secrets operator (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) -- the operator syncs secrets into Kubernetes Secrets from an external store, enabling centralized rotation; (3) Vault Agent Injector -- injects secrets directly into the pod filesystem via a sidecar, no Kubernetes Secret created. For most organizations, the external secrets operator with AWS Secrets Manager or HashiCorp Vault provides the best combination of security and operational simplicity.
What is the difference between Trivy and Snyk for container scanning?
Both scan for CVEs in OS packages and application dependencies. Key differences: Trivy is open-source and free, runs entirely locally, and integrates easily into any CI pipeline without sending data to an external service. Snyk is a commercial platform with a free tier; it offers more polished developer tooling, PR-level remediation suggestions, and a centralized dashboard for tracking vulnerabilities across all images. For organizations that want to keep scan data in-house and avoid SaaS dependencies, Trivy is the better choice. For organizations that want developer-friendly workflows with centralized reporting, Snyk provides more value despite the cost.
Should we use Alpine or distroless as our base image?
It depends on whether your application binary is statically compiled. For statically compiled binaries (Go with CGO_ENABLED=0, Rust): use distroless/static or scratch -- these have the smallest attack surface and zero extraneous packages. For dynamically linked binaries that need glibc: use distroless/base (glibc) rather than Alpine (musl libc) to avoid compatibility issues. For applications where you need a shell during container startup (init scripts, entrypoint.sh): Alpine is appropriate, as it provides a shell with a minimal package surface. The general rule: start with the most minimal image your application can run on, and only move to a larger base image when you have a specific, documented requirement.
What is cosign and do we need to implement image signing?
Cosign (part of the Sigstore project) signs container images with cryptographic signatures that can be verified before a container is allowed to run. Without image signing, anyone with push access to your registry can push a malicious image under a trusted tag. With signing enforced at the Kubernetes admission controller (via Kyverno or OPA/Gatekeeper), only images signed by your CI/CD pipeline's OIDC identity will run in your cluster. For organizations running sensitive workloads in Kubernetes, image signing is a recommended control. The keyless signing model (using OIDC from GitHub Actions or other CI systems) eliminates the need to manage long-lived signing keys, making it accessible to most teams.
Sources & references
Free resources
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.
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.
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, weekly, no spam.
Unsubscribe anytime. We never sell your data.

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 weekly.
The Mythos Brief is free.
AI that finds 27-year-old zero-days. What it means for your security program.
