Docker Image Security Scanning: Stop Shipping Vulnerable Containers to Production
Docker image security scanning explained with real production failures.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓Docker CLI basics (build, run, push)
- ✓Understanding of container images and layers
- ✓Familiarity with CI/CD pipelines
To scan a Docker image for vulnerabilities, run trivy image your-image:tag. This checks all installed packages against the CVE database. For CI/CD, add a scan step that fails the build if critical vulnerabilities are found. Use .trivyignore to suppress false positives.
Think of a Docker image like a pre-packed suitcase for your app. Security scanning is like an airport security check that X-rays every item inside. It flags any banned items (vulnerable packages) or suspicious objects (secrets). You wouldn't fly without checking your bag, so don't deploy without scanning your image.
You just pushed a Docker image to production. Two hours later, your security team calls: 'We found a critical CVE in your base image.' Now you're scrambling to rebuild, retest, and redeploy. Sound familiar? Docker image security scanning isn't optional anymore — it's the difference between a routine deploy and a breach notification.
The problem is simple: your image is a stack of layers, each potentially carrying vulnerable packages. Base images like node:18 or python:3.11 are updated frequently, but your Dockerfile might pin an old version. Without scanning, you're blind to known vulnerabilities until it's too late.
By the end of this, you'll be able to integrate vulnerability scanning into your Docker build pipeline, interpret scan results like a pro, and avoid the gotchas that burn teams in production. You'll know which tool to use when, how to handle false positives, and how to set up automated gates that stop vulnerable images from ever reaching your registry.
Why You Can't Trust Your Base Image
Every Docker image starts with a base — FROM node:18, FROM python:3.11-slim, FROM alpine:3.18. These images are maintained by communities, but they're not immune to vulnerabilities. A single outdated package in the base can expose your entire application.
Consider this: the node:18 image contains over 100 OS packages (glibc, openssl, zlib, etc.). Each has a version string that maps to CVEs. If you don't scan, you're flying blind. The worst part? Base images are updated frequently, but your Dockerfile might pin a specific tag like node:18 which points to a moving target. Today's node:18 is not tomorrow's node:18.
The fix: always pin base images by digest (sha256:...) and scan them before building your app layer. This gives you reproducible builds and a known vulnerability baseline.
node:18 without a digest means your CI might build against a different image today than yesterday. If the base image gets a security update, your build might suddenly include new packages — or worse, break. Always pin digests in production.Choosing the Right Scanner: Trivy vs Grype vs Docker Scout
You have options. Trivy (Aqua Security) is the Swiss Army knife — scans OS packages, language-specific deps, IaC misconfigurations, and even Kubernetes manifests. Grype (Anchore) focuses on container images and integrates tightly with Syft for SBOM generation. Docker Scout is Docker's own offering, baked into Docker Desktop and Hub.
Which one should you use? If you're already in the Docker ecosystem and want zero-config, Docker Scout is tempting. But it's a SaaS product — your image data leaves your network. For air-gapped environments or strict compliance, Trivy or Grype are better. Trivy wins on speed and breadth: it can scan a 1GB image in under 30 seconds. Grype is slower but produces more detailed SBOMs.
My take: use Trivy for CI/CD gates (fast, reliable) and Grype for compliance audits (detailed SBOM). Docker Scout is fine for local dev awareness but don't rely on it as your only gate.
~/.cache/trivy directory between builds to avoid downloading 50MB every time. Use --skip-db-update if the DB is fresh (e.g., updated once per day).Integrating Scanning into CI/CD: The Gate That Actually Works
A scan that doesn't block the pipeline is just a report nobody reads. You need a hard gate: if the image has any CRITICAL vulnerability with a known fix, fail the build. But beware — not all CVEs are equal. Some are in unused packages, some have no exploit in the wild, and some are in test dependencies.
The trick: use --fail-on with a severity threshold, but also maintain a .trivyignore for accepted risks. Every ignored CVE must have a comment with a ticket number and expiration date. This prevents 'ignore and forget'.
Here's a production-grade CI step for GitHub Actions that scans, fails on critical/high, and allows exemptions.
--ignore-unfixed flag is critical. Without it, you'll fail on CVEs that have no patch yet — blocking your deploy for a problem you can't fix. Always use this flag in CI, and track unfixed CVEs separately with a risk acceptance process.Handling False Positives and Ignored CVEs
No scanner is perfect. You'll get false positives — a CVE in a library you don't use, or a vulnerability that's only exploitable with a specific configuration you don't have. Ignoring them is fine, but do it right.
Create a .trivyignore file in your repo. Each line is a CVE ID followed by a comment. The comment must include a ticket number and an expiration date. This forces periodic review. If the CVE is still unfixed after 90 days, your team must re-evaluate.
Example: CVE-2024-1234 # OPS-5678: Accepted risk until 2024-06-01. Library not loaded at runtime.
Never ignore a CVE without a ticket. That's how vulnerabilities become 'technical debt' that never gets paid.
Scanning During Build: Multi-Stage and Distroless Images
Multi-stage builds are great for reducing image size, but they complicate scanning. You need to scan the final stage, not the builder stage. The builder stage might have compilers and dev tools with CVEs, but they're not in the final image.
Distroless images (e.g., gcr.io/distroless/nodejs) are minimal — no shell, no package manager. This reduces the attack surface dramatically. But they also make scanning harder because there's no package database to query. Trivy handles this by scanning the binary layers directly.
Best practice: scan both the builder stage (for awareness) and the final stage (for the gate). Fail only on the final stage.
--severity CRITICAL for builder stage, fail only on final.Secrets Scanning: The CVE Nobody Talks About
Vulnerabilities are one thing, but hardcoded secrets in your image are a direct line to your infrastructure. Docker images are often shared via registries — if you accidentally include an AWS key or a database password, anyone with pull access can use it.
Trivy has a built-in secrets scanner (--scanners secret). It checks for patterns like AWS keys, GitHub tokens, private keys, and more. Run it as part of your scan. The output is clear: file path, line number, and the type of secret.
Never build an image with secrets. Use Docker build secrets (--secret) or multi-stage builds to avoid copying sensitive files into the final image.
Scanning in Air-Gapped Environments
If your production environment has no internet access (air-gapped), you can't download vulnerability databases on the fly. You need to pre-download the DB and make it available to the scanner.
Trivy supports offline mode. Download the DB on a connected machine, then transfer it to the air-gapped environment. Use --cache-dir to point to the local DB. Update the DB periodically (e.g., weekly) and re-scan your images.
Grype has a similar approach with its grype db update command. Both tools allow you to specify a local DB path.
When Not to Use Image Scanning (And What to Do Instead)
Image scanning is not a silver bullet. It won't catch zero-day vulnerabilities (no known CVE yet), logic flaws in your application, or misconfigurations in your runtime environment. It also won't help if your base image is completely custom and not in any CVE database.
For zero-days, you need runtime security monitoring (e.g., Falco, Sysdig). For application logic, you need SAST/DAST tools. For misconfigurations, use Kubernetes security policies (OPA, Kyverno).
Also, scanning every image in a large registry daily can be expensive. Prioritize: scan production images weekly, development images on every build. Use differential scanning — only scan layers that changed since the last scan.
The 4GB Container That Kept Dying
node:14-slim contained a vulnerable version of glibc (CVE-2021-33574) that caused a memory corruption in DNS resolution under high concurrency. The vulnerability was known for 6 months, but the image was never rescanned.node:14-slim with updated glibc (or moved to node:16-slim). Added weekly scheduled scans with trivy image --severity HIGH,CRITICAL and a CI gate that fails if any critical CVE is older than 30 days.- A vulnerability in your base image's OS packages can manifest as a hard-to-diagnose runtime crash.
- Scan early, scan often, and pin base image digests, not tags.
docker pull myapp:latest. 2. Check registry credentials: docker login. 3. Ensure image tag is correct. 4. If using private registry, pass --registry.username and --registry.password to Trivy.trivy image --update-db. 2. Verify the image has OS packages: docker run --rm myapp:latest cat /etc/os-release. 3. Try a different scanner (Grype) to cross-validate.TRIVY_CACHE_DIR and persist between runs. 2. Use --skip-db-update if DB is fresh. 3. Limit severity: --severity CRITICAL,HIGH. 4. Consider incremental scanning with --pkg-types os.docker pull myapp:latestdocker logintrivy image --registry.username $USER --registry.password $PASS myapp:latest| File | Command / Code | Purpose |
|---|---|---|
| Dockerfile | FROM node:18@sha256:abc123def456... | Why You Can't Trust Your Base Image |
| scan-comparison.sh | brew install trivy | Choosing the Right Scanner |
| .github | name: Docker Security Scan | Integrating Scanning into CI/CD |
| .trivyignore | CVE-2024-1234 # OPS-5678: Accepted until 2024-06-01. Library not loaded at runti... | Handling False Positives and Ignored CVEs |
| Dockerfile.multistage | FROM node:18-alpine AS builder | Scanning During Build |
| scan-secrets.sh | trivy image --scanners secret myapp:latest | Secrets Scanning |
| airgap-scan.sh | trivy image --download-db-only --cache-db /tmp/trivy-db | Scanning in Air-Gapped Environments |
Key takeaways
--ignore-unfixed in CI to avoid failing on CVEs without patches; track unfixed CVEs separately with expiration dates.Interview Questions on This Topic
How does Trivy handle scanning a distroless image that has no package manager?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Docker. Mark it forged?
4 min read · try the examples if you haven't