Home DevOps Docker Image Security Scanning: Stop Shipping Vulnerable Containers to Production
Advanced 4 min · July 11, 2026

Docker Image Security Scanning: Stop Shipping Vulnerable Containers to Production

Docker image security scanning explained with real production failures.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 11, 2026
last updated
258
articles · all by Naren
Before you start⏱ 30 min
  • Docker CLI basics (build, run, push)
  • Understanding of container images and layers
  • Familiarity with CI/CD pipelines
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

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.

✦ Definition~90s read
What is Docker Image Security Scanning?

Docker image security scanning is the automated process of analyzing container images for known vulnerabilities (CVEs), misconfigurations, and secrets. Tools like Trivy, Grype, and Docker Scout compare image contents against vulnerability databases to flag risks before deployment.

Think of a Docker image like a pre-packed suitcase for your app.
Plain-English First

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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — DevOps tutorial

# Bad: tag-based, changes without notice
# FROM node:18

# Good: digest-based, reproducible
FROM node:18@sha256:abc123def456...

# Now scan this base image before adding your code
# $ trivy image node:18@sha256:abc123def456...

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Output
No output — this is a Dockerfile example.
Production Trap: Tag Drift
Using 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.

scan-comparison.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — DevOps tutorial

# Install Trivy (macOS)
brew install trivy

# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# Scan with Trivy (fast)
trivy image --severity CRITICAL,HIGH --no-progress myapp:latest

# Scan with Grype (detailed SBOM)
grype myapp:latest --only-fixed --fail-on high

# Docker Scout (requires Docker Desktop)
docker scout quickview myapp:latest
Output
Trivy output (example):
2024-01-15T10:00:00Z INFO Vulnerability scanning is enabled
2024-01-15T10:00:30Z INFO Number of language-specific files: 0
2024-01-15T10:00:30Z INFO Found 2 vulnerabilities (CRITICAL: 1, HIGH: 1)
Total: 2 (CRITICAL: 1, HIGH: 1)
┌──────────┬────────────────┬──────────┬──────────┐
│ Library │ Vulnerability │ Severity │ Status │
├──────────┼────────────────┼──────────┼──────────┤
│ libssl │ CVE-2024-1234 │ CRITICAL │ fixed in │
│ curl │ CVE-2024-5678 │ HIGH │ fixed in │
└──────────┴────────────────┴──────────┴──────────┘
Senior Shortcut: Cache the DB
Trivy downloads its vulnerability database on first run. In CI, cache the ~/.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.

.github/workflows/docker-scan.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// io.thecodeforge — DevOps tutorial

name: Docker Security Scan

on:
  push:
    branches: [main]
  pull_request:

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'  # Fail on any critical/high
          ignore-unfixed: true  # Only fail on vulnerabilities with a fix

      - name: Upload Trivy results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Fail if critical vulnerabilities older than 30 days
        run: |
          # Custom check: fail if any critical CVE has been known for >30 days
          trivy image --severity CRITICAL --format json myapp:${{ github.sha }} | \
            jq -e '.Results[].Vulnerabilities[] | select(.PublishedDate < (now - 30*86400))' > /dev/null && \
            echo "Found old critical CVE" && exit 1 || echo "All critical CVEs are recent"
Output
On failure:
Error: exit code 1
Found old critical CVE
On success:
All critical CVEs are recent
Interview Gold: Ignoring Unfixed Vulnerabilities
The --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.

.trivyignoreTEXT
1
2
3
4
5
6
7
8
// io.thecodeforge — DevOps tutorial

# Accepted risks - review quarterly
CVE-2024-1234 # OPS-5678: Accepted until 2024-06-01. Library not loaded at runtime.
CVE-2023-4567 # OPS-5679: Accepted until 2024-09-01. No known exploit in the wild.

# False positive - not applicable to our architecture
CVE-2022-7890 # OPS-5680: False positive. Only affects Windows builds.
Never Do This: Ignoring Without a Ticket
I've seen teams with 50+ ignored CVEs and zero documentation. When an auditor asks 'why is this CVE accepted?', nobody knows. Always link to a ticket. Always set an expiration. Otherwise, you're accumulating risk silently.

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.

Dockerfile.multistageDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — DevOps tutorial

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["dist/server.js"]

# Scan the final stage:
# $ trivy image myapp:latest
# Note: distroless images have no package manager, but Trivy scans binaries.
Senior Shortcut: Scan Builder Stage Too
Scan the builder stage in CI to catch vulnerabilities in dev dependencies. Even though they're not in the final image, they could be exploited if someone gains access to your build environment. Use --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.

scan-secrets.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — DevOps tutorial

# Scan for secrets in the image
trivy image --scanners secret myapp:latest

# Example output:
# 2024-01-15T10:00:00Z    INFO    Secret scanning is enabled
# Total: 1 secret
# ┌─────────────────────┬──────────────────┬──────────┐
# │ FileSecret TypeSeverity │
# ├─────────────────────┼──────────────────┼──────────┤
# │ /app/.env           │ AWS Access KeyCRITICAL │
# └─────────────────────┴──────────────────┴──────────┘

# Fix: remove the .env file from the image. Use Docker secrets or env vars at runtime.
Output
See inline comment above.
Production Trap: .env in the 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.

airgap-scan.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — DevOps tutorial

# On connected machine: download Trivy DB
trivy image --download-db-only --cache-db /tmp/trivy-db

# Copy /tmp/trivy-db to air-gapped environment (e.g., via USB)

# On air-gapped machine: scan using local DB
trivy image --cache-dir /path/to/trivy-db --skip-db-update myapp:latest

# For Grype:
# Download DB: grype db update -o /tmp/grype-db
# Scan: grype myapp:latest --db /tmp/grype-db
Interview Gold: Air-Gapped Scanning
In air-gapped environments, you must plan for DB updates. A common mistake is using a stale DB (months old) and missing recent CVEs. Automate the DB download on a connected machine and transfer it regularly. Set up a cron job to re-scan images after each DB update.

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.

When Simpler Is Better
If you're a small team with 5 microservices, don't over-engineer. Use Trivy in CI with default settings. The complexity of Grype's SBOM and Docker Scout's SaaS model isn't worth it. Start simple, add sophistication only when you need it.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A microservice crashed every 6 hours with OOMKilled. Memory limit was 4GB, but RSS stayed under 2GB.
Assumption
Team assumed a memory leak in the Node.js app. Spent days profiling heap dumps.
Root cause
The base image 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.
Fix
Switched base image to 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.
Key lesson
  • 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.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Scan fails with 'FATAL: unable to get image' or 'manifest unknown'
Fix
1. Verify image exists: 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.
Symptom · 02
Scan reports 0 vulnerabilities on an image you know has CVEs
Fix
1. Check if the scanner's DB is outdated: 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.
Symptom · 03
CI scan takes >10 minutes, slowing down pipeline
Fix
1. Cache Trivy DB: set 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 Image Security Scanning Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
`FATAL: unable to get image`
Immediate action
Check if image exists and registry is accessible
Commands
docker pull myapp:latest
docker login
Fix now
Use trivy image --registry.username $USER --registry.password $PASS myapp:latest
`no vulnerabilities found` on known vulnerable image+
Immediate action
Update vulnerability database
Commands
trivy image --update-db
trivy image --severity CRITICAL myapp:latest
Fix now
If still no results, try Grype: grype myapp:latest
Scan takes >10 min in CI+
Immediate action
Cache the DB and skip update
Commands
export TRIVY_CACHE_DIR=/tmp/trivy-cache
trivy image --cache-dir $TRIVY_CACHE_DIR --skip-db-update myapp:latest
Fix now
Add a cron job to update DB daily, then use --skip-db-update in CI
`CVE-2024-XXXX` is a false positive+
Immediate action
Add to `.trivyignore` with ticket and expiration
Commands
echo 'CVE-2024-XXXX # OPS-1234: Accepted until 2024-12-01. Not exploitable.' >> .trivyignore
trivy image --ignorefile .trivyignore myapp:latest
Fix now
Ensure the ignore file is committed and reviewed quarterly
FeatureTrivyGrypeDocker Scout
Speed (1GB image)<30s~60s~45s
Offline supportYes (download DB)Yes (download DB)No (SaaS)
Secrets scanningYesNo (use Syft)No
IaC scanningYesNoNo
SBOM generationYes (via CycloneDX)Yes (native)Yes
CI/CD integrationGitHub, GitLab, JenkinsGitHub, GitLab, JenkinsGitHub, Docker Hub
CostFree, open sourceFree, open sourceFree tier, paid for advanced
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
DockerfileFROM node:18@sha256:abc123def456...Why You Can't Trust Your Base Image
scan-comparison.shbrew install trivyChoosing the Right Scanner
.githubworkflowsdocker-scan.ymlname: Docker Security ScanIntegrating Scanning into CI/CD
.trivyignoreCVE-2024-1234 # OPS-5678: Accepted until 2024-06-01. Library not loaded at runti...Handling False Positives and Ignored CVEs
Dockerfile.multistageFROM node:18-alpine AS builderScanning During Build
scan-secrets.shtrivy image --scanners secret myapp:latestSecrets Scanning
airgap-scan.shtrivy image --download-db-only --cache-db /tmp/trivy-dbScanning in Air-Gapped Environments

Key takeaways

1
Pin base images by digest, not tag, to ensure reproducible builds and a known vulnerability baseline.
2
Use --ignore-unfixed in CI to avoid failing on CVEs without patches; track unfixed CVEs separately with expiration dates.
3
Always scan for secrets in addition to CVEs
a hardcoded AWS key is worse than any CVE.
4
In air-gapped environments, pre-download the vulnerability DB and update it regularly; schedule re-scans after each DB update.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Trivy handle scanning a distroless image that has no package ma...
Q02SENIOR
When would you choose Grype over Trivy in a production environment?
Q03SENIOR
What happens if you run `trivy image` without `--ignore-unfixed` and a C...
Q04JUNIOR
What is the difference between a CVE and a vulnerability in the context ...
Q05SENIOR
You scan an image and find a critical CVE in a library that is only used...
Q06SENIOR
How would you design a scanning pipeline for a registry with 10,000 imag...
Q01 of 06SENIOR

How does Trivy handle scanning a distroless image that has no package manager?

ANSWER
Trivy scans the binary layers directly by parsing the filesystem and identifying known binaries (e.g., libssl.so). It matches these against its vulnerability database using hashes or version strings embedded in the binaries. No package database is needed.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
How do I scan a Docker image for vulnerabilities?
02
What's the difference between Trivy and Docker Scout?
03
How do I ignore a false positive vulnerability in Trivy?
04
Can I scan a Docker image without pulling it first?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 11, 2026
last updated
258
articles · all by Naren
🔥

That's Docker. Mark it forged?

4 min read · try the examples if you haven't

Previous
Docker Healthchecks and Restart Policies
26 / 32 · Docker
Next
Docker Monitoring and Logging