Container image security
In this series (8 parts)
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:
- Build from a minimal base image (Alpine, distroless, or scratch).
- Scan with Trivy in CI. Fail the build on critical and high CVEs.
- Triage accepted CVEs in
.trivyignorewith documented reasons. - Sign the image with cosign before pushing to the registry.
- Run containers with
--read-only,--cap-drop ALL, and--security-opt=no-new-privileges. - 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.