Docker Image Vulnerability Scanning with Trivy
Trivy scans Docker images for vulnerabilities in OS packages and language-specific dependencies (npm, Maven, pip, Go modules). Run it locally with trivy image myapp:latest. In CI, use --exit-code 1 --severity CRITICAL,HIGH to fail builds on serious findings. False positives can be suppressed with a .trivyignore file.
Shipping a Docker image without scanning it is like deploying code without running tests. The vulnerabilities are there whether you look for them or not, but if you look, you can fix them before they reach production. Trivy is the most practical tool for this job: it is fast, accurate, requires no server, and integrates naturally into any CI pipeline.
This post covers everything you need to go from zero to a working vulnerability gate in your pipeline — CLI usage, output formats, severity thresholds, GitHub Actions integration, and a pragmatic approach to false positives.
What Trivy Scans
Trivy operates at two levels: the OS layer and the application layer.
OS packages covers everything installed by the package manager in your base image. If you are using ubuntu:22.04 as your base, Trivy checks every apt-installed package against vulnerability databases from NVD, GitHub Security Advisories, and distribution-specific sources like Ubuntu USN and Alpine secdb.
Language dependencies covers the packages your application code depends on:
- Node.js:
package-lock.json,yarn.lock,pnpm-lock.yaml - Java:
pom.xml,build.gradle, JAR manifests - Python:
requirements.txt,Pipfile.lock,poetry.lock - Go:
go.sum - Ruby:
Gemfile.lock - Rust:
Cargo.lock
Trivy finds these lock files inside the image filesystem and checks them against the language-specific advisory databases. This means even a minimal Alpine-based image with a Node.js application will have its node_modules scanned.
Installing Trivy
On macOS:
brew install aquasecurity/trivy/trivyOn Linux (Debian/Ubuntu):
sudo apt-get install wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivyOr use the official Docker image — no installation needed:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image myapp:latestBasic CLI Usage
Scan an image:
trivy image myapp:latestThis pulls vulnerability data (cached locally after the first run), inspects the image, and prints a table showing each vulnerability's ID, severity, installed version, and fixed version.
A typical output looks like:
myapp:latest (ubuntu 22.04)
Total: 23 (UNKNOWN: 0, LOW: 10, MEDIUM: 8, HIGH: 4, CRITICAL: 1)
┌──────────────────┬────────────────┬──────────┬──────────────────────┬──────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├──────────────────┼────────────────┼──────────┼──────────────────────┼──────────────────┤
│ libssl3 │ CVE-2024-0727 │ CRITICAL │ 3.0.2-0ubuntu1.14 │ 3.0.2-0ubuntu1.15│
│ curl │ CVE-2024-2398 │ HIGH │ 7.81.0-1ubuntu1.16 │ 7.81.0-1ubuntu1.17│
└──────────────────┴────────────────┴──────────┴──────────────────────┴──────────────────┘Scan only specific severity levels:
trivy image --severity HIGH,CRITICAL myapp:latestScan a filesystem directory instead of an image (useful during development before building):
trivy fs ./srcScan a Dockerfile for misconfigurations:
trivy config ./DockerfileOutput Formats
The default table format is readable for humans but hard to parse in scripts. Trivy supports several output formats:
JSON — machine-readable, good for custom processing:
trivy image --format json --output report.json myapp:latestSARIF — for GitHub Advanced Security code scanning integration:
trivy image --format sarif --output report.sarif myapp:latestTemplate — fully customizable output using Go templates:
trivy image --format template --template "@contrib/html.tpl" --output report.html myapp:latestFor CI pipelines that need to store results as artifacts, JSON or SARIF are the most useful. SARIF specifically enables inline annotations on pull requests when combined with GitHub's code scanning upload action.
Severity Thresholds and Exit Codes
This is the critical piece for CI integration. By default, Trivy exits with code 0 even when it finds vulnerabilities — it reports them but does not fail the build. To fail the build, use --exit-code:
trivy image --exit-code 1 --severity CRITICAL myapp:latestThis exits with code 1 if any CRITICAL vulnerabilities are found, which causes the CI step to fail.
A practical tiered approach:
# Fail immediately on CRITICAL
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Report HIGH but do not fail (yet)
trivy image --exit-code 0 --severity HIGH myapp:latestThe rationale: CRITICAL vulnerabilities typically have working exploits and should block a release. HIGH vulnerabilities are serious but may not have a fixed version available, so blocking on them can create noise that causes teams to disable scanning entirely. Start with CRITICAL-only blocking and tighten the threshold as your team builds the habit of addressing findings.
You can also skip specific vulnerability IDs that have been triaged and accepted:
trivy image --skip-dirs /usr/local/lib/python3.11 myapp:latest
trivy image --ignore-unfixed myapp:latest--ignore-unfixed is particularly useful: it skips any vulnerability where no fixed version exists yet. These are genuinely unactionable — you cannot upgrade to a version that does not exist — so excluding them reduces noise without hiding real risk.
GitHub Actions Integration
Here is a complete GitHub Actions workflow that scans on every push and pull request:
name: Container Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
trivy-scan:
runs-on: ubuntu-latest
permissions:
security-events: write # Required for SARIF upload
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan for CRITICAL vulnerabilities (hard gate)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: '1'
severity: CRITICAL
ignore-unfixed: true
- name: Full scan (upload to GitHub Security)
uses: aquasecurity/trivy-action@master
if: always()
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH,MEDIUM
ignore-unfixed: true
- name: Upload SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarifThe two-step approach here is intentional:
- The first scan uses
exit-code: 1and only checks CRITICAL. This is the hard gate. - The second scan runs with
if: always()even if the first step failed, and uploads full results to the GitHub Security tab for visibility into MEDIUM and HIGH findings without blocking the build.
This gives you a blocking gate for the most serious issues while keeping the broader picture visible.
Caching the Vulnerability Database
Trivy downloads its vulnerability database on the first run and refreshes it periodically. In CI, you can cache this database to speed up scans significantly. Without caching, database download adds 30–60 seconds per run.
- name: Cache Trivy vulnerability database
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: '1'
severity: CRITICAL
cache-dir: ~/.cache/trivyThe cache key uses restore-keys with a prefix so it always restores the most recent cache, even if the exact key (which includes the run ID) does not match.
Ignoring False Positives
Not every reported vulnerability is actually exploitable in your environment. A vulnerability in a library feature you do not use, or one that requires local access your container does not expose, may be a false positive for your threat model.
Create a .trivyignore file in your repository root:
# CVE-2023-44487 (HTTP/2 Rapid Reset) - not exposed externally
CVE-2023-44487
# CVE-2024-0727 - affects OpenSSL server functionality only, we use client only
CVE-2024-0727
# Accepted risk - no fixed version available, severity MEDIUM
CVE-2023-52425Reference it in your Trivy invocation:
trivy image --ignorefile .trivyignore myapp:latestOr in GitHub Actions:
with:
trivyignores: .trivyignoreEvery entry in .trivyignore should have a comment explaining why it was accepted. This creates an audit trail and prevents the file from becoming a dump of "ignored because it was annoying" entries that erodes your security posture.
For team workflows, consider adding an expiry convention:
# CVE-2024-1234 - no fix available, review again 2026-09-01
CVE-2024-1234A periodic review of the ignore file catches cases where a fix became available after you first triaged the issue.
Integrating Trivy into a Java Build
For Java projects using Maven, you can run Trivy against the built image as part of your Maven lifecycle using the exec plugin:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>trivy-scan</id>
<phase>verify</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>trivy</executable>
<arguments>
<argument>image</argument>
<argument>--exit-code</argument>
<argument>1</argument>
<argument>--severity</argument>
<argument>CRITICAL</argument>
<argument>--ignore-unfixed</argument>
<argument>${project.artifactId}:${project.version}</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>This runs the scan during the verify phase, after the Docker image is built but before deployment.
Choosing a Minimal Base Image
The most effective way to reduce vulnerability findings is to reduce the attack surface of your base image. Fewer packages means fewer vulnerabilities.
Common base image choices in order of attack surface (smallest to largest):
scratch— no OS, statically compiled binaries onlygcr.io/distroless/java21— Google's distroless, no shell, no package managereclipse-temurin:21-jre-alpine— Alpine-based, minimal package seteclipse-temurin:21-jre— Debian-based, larger but well-maintainedubuntu:22.04— full Ubuntu, maximum package surface
A scan of the same Java application on eclipse-temurin:21-jre-alpine versus eclipse-temurin:21-jre-jammy typically shows 20–40% fewer vulnerabilities on Alpine, simply because Alpine has fewer packages installed by default.
What Trivy Does Not Cover
Trivy focuses on known CVEs in packages and dependencies. It does not replace other security practices:
- It does not find business logic vulnerabilities in your own code
- It does not test runtime behavior or network exposure
- It does not check secrets or credentials embedded in images (use
trivy image --scanners secretfor that — it is a separate scanner)
Enable secret scanning alongside vulnerability scanning:
trivy image --scanners vuln,secret myapp:latestThis catches accidentally committed API keys, tokens, and private keys that were baked into the image.
Wrapping Up
Trivy is the fastest path from "we don't scan our images" to "every build is scanned and critical CVEs block deployment". Start with a single GitHub Actions step using --exit-code 1 --severity CRITICAL --ignore-unfixed. Add the SARIF upload to make findings visible in GitHub's Security tab. Build the .trivyignore discipline from day one. The investment is an hour of setup; the return is a continuous, automated line of defense that catches vulnerabilities before they reach production.