Search…

Container image security

In this series (8 parts)
  1. How containers work
  2. Docker fundamentals
  3. Writing production-quality Dockerfiles
  4. Docker networking
  5. Docker volumes and storage
  6. Docker Compose
  7. Container image security
  8. Docker in CI/CD pipelines

A container image is just a tarball of files. If those files include a library with a known remote code execution vulnerability, your production workload inherits that risk. Security is not a phase you bolt on at the end. It belongs in the build pipeline, in the base image selection, and in how you run containers. For a broader look at security across the full delivery chain, see container security in DevSecOps.

Scanning images for vulnerabilities

Two tools dominate image scanning today: Trivy (open source, by Aqua Security) and Docker Scout (built into Docker Desktop).

Trivy

Trivy scans container images, filesystems, and git repositories. It checks OS packages and application dependencies against vulnerability databases.

Scan an image:

trivy image myapp:latest

Scan only for critical and high severity issues:

trivy image --severity CRITICAL,HIGH myapp:latest

Scan the local filesystem (useful in CI before the image is built):

trivy fs --severity CRITICAL,HIGH .

Exit with a non-zero code when vulnerabilities are found, which fails the CI job:

trivy image --exit-code 1 --severity CRITICAL,HIGH myapp:latest

Docker Scout

Docker Scout integrates with Docker Desktop and the CLI. It provides CVE data along with remediation recommendations.

List CVEs for an image:

docker scout cves myapp:latest

Get base image upgrade recommendations:

docker scout recommendations myapp:latest

Scout is useful for quick local feedback. Trivy is more common in CI pipelines because it runs without Docker Desktop.

Security scanning pipeline

A typical pipeline gates the push to a registry on scan results.

flowchart LR
  A[Build Image] --> B[Run Trivy Scan]
  B --> C{Critical or High CVEs?}
  C -- Yes --> D[Fail Build]
  C -- No --> E[Push to Registry]
  E --> F[Sign Image]

Build, scan, gate, push, sign. A failed scan blocks the image from reaching any registry.

CVE triage

Not every CVE requires immediate action. Scanners report everything, and the noise can be overwhelming. Triage means deciding what to fix, what to accept, and what to ignore.

Severity levels:

  • Critical: Remote code execution, authentication bypass. Fix immediately.
  • High: Privilege escalation, significant data exposure. Fix in the current sprint.
  • Medium: Requires local access or unusual configuration to exploit. Schedule a fix.
  • Low/Negligible: Theoretical risk with no known exploit. Accept and revisit later.

Real-world triage decisions:

A critical CVE in libcurl matters if your app makes HTTP calls. The same CVE in a build tool that never runs in production does not. Context determines urgency.

A high CVE in the base image’s login utility is irrelevant if no user ever shells into the container. You can accept it, but document why.

The .trivyignore file

When you accept a CVE after triage, record it in .trivyignore so the scanner stops flagging it:

# CVE-2023-44487: HTTP/2 rapid reset. Mitigated at load balancer level.
CVE-2023-44487

# CVE-2023-39325: Go net/http2 rapid reset. App uses HTTP/1.1 only.
CVE-2023-39325

Trivy reads this file automatically. Always include a comment explaining why the CVE is accepted. Future you will not remember.

Minimal base images

The fewer packages in an image, the smaller the attack surface. Three strategies exist.

Alpine Linux replaces glibc with musl libc and ships a minimal package set. Most images drop from hundreds of megabytes to under 10.

FROM node:22-alpine

Distroless images from Google contain only the application runtime. No shell, no package manager, no coreutils. Debugging is harder, but attackers have almost nothing to work with.

FROM gcr.io/distroless/nodejs22-debian12

Scratch is an empty image. You copy in a statically compiled binary and nothing else. Common for Go applications.

FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]

The tradeoff is clear: less tooling in the image means fewer vulnerabilities but harder debugging. In production, distroless or scratch images are worth the inconvenience.

Runtime hardening

Image scanning catches known vulnerabilities. Runtime hardening limits what a compromised container can do.

Read-only root filesystem

Prevent the container from writing to its own filesystem. This stops many post-exploitation techniques that drop malware to disk.

docker run --read-only --tmpfs /tmp myapp:latest

The --tmpfs /tmp flag mounts a writable tmpfs for applications that need a scratch directory. Everything else is read-only.

Dropping Linux capabilities

By default, Docker grants containers a set of Linux capabilities. Most applications need almost none of them.

Drop all capabilities, then add back only what the application requires:

docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myapp:latest

NET_BIND_SERVICE allows binding to ports below 1024. If your app listens on port 8080, you do not even need that.

No new privileges

Prevent processes inside the container from gaining additional privileges through setuid binaries or other mechanisms:

docker run --security-opt=no-new-privileges myapp:latest

Combined example

A hardened container run command looks like this:

docker run \
  --read-only \
  --tmpfs /tmp \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt=no-new-privileges \
  -p 443:8443 \
  myapp:latest

This container cannot write to its filesystem (except /tmp), has almost no Linux capabilities, and cannot escalate privileges. Even if an attacker achieves code execution, the blast radius is small.

Signing images

Scanning tells you an image is safe. Signing tells you an image is authentic. Without signatures, a compromised registry or a man-in-the-middle attack can substitute a malicious image.

Docker Content Trust

Docker Content Trust (DCT) uses Notary to sign and verify images. Enable it by setting an environment variable:

export DOCKER_CONTENT_TRUST=1

With DCT enabled, docker push signs the image automatically, and docker pull rejects unsigned images. The first push generates signing keys that you must store securely.

Cosign

Cosign (from the Sigstore project) is the newer alternative. It supports keyless signing with OIDC identity providers, which eliminates key management.

Sign an image:

cosign sign myregistry.io/myapp:latest

Verify an image:

cosign verify --certificate-identity=ci@myorg.com \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  myregistry.io/myapp:latest

In CI, cosign with keyless signing ties the signature to the GitHub Actions workflow identity. No secrets to rotate, no keys to leak.

Putting it together

A secure image pipeline combines all of these practices:

  1. Build from a minimal base image (Alpine, distroless, or scratch).
  2. Scan with Trivy in CI. Fail the build on critical and high CVEs.
  3. Triage accepted CVEs in .trivyignore with documented reasons.
  4. Sign the image with cosign before pushing to the registry.
  5. Run containers with --read-only, --cap-drop ALL, and --security-opt=no-new-privileges.
  6. Rebuild images regularly. A clean scan today does not mean a clean scan next month.

What comes next

Securing individual images is one layer. The next step is orchestrating secure containers at scale, managing secrets, enforcing network policies, and automating security checks across a fleet of services. Kubernetes adds its own security model on top of everything covered here.

Start typing to search across all content
navigate Enter open Esc close