Scanning Container Images for CVEs with Trivy in CI
Add Trivy to your CI pipeline to catch known vulnerabilities before images reach production. Covers severity thresholds, ignoring false positives, scanning Helm charts, and breaking the build on critical CVEs.
Before you begin
- A GitHub Actions workflow that builds Docker images
- Basic Docker knowledge
Trivy is a vulnerability scanner for container images, filesystems, Git repositories, and Kubernetes clusters. It's fast, has no daemon, and works in CI without any setup beyond installing the binary.
This tutorial adds Trivy to a GitHub Actions workflow, configures severity thresholds, and shows you how to handle false positives.
Step 1: Run Trivy Locally First
Before adding it to CI, understand what it finds:
# Install Trivy
brew install trivy # macOS
# Or:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.50.0
# Scan a local image
trivy image nginx:1.25
# Scan with severity filter
trivy image --severity HIGH,CRITICAL nginx:1.25
# Output as JSON for further processing
trivy image --format json --output results.json nginx:1.25
# Scan your own built image
docker build -t my-app:local .
trivy image my-app:local
Trivy checks:
- OS packages (apt, apk, rpm)
- Language packages (npm, pip, gem, go modules, Maven)
- Container configuration (Dockerfile misconfigs)
Step 2: Add Trivy to GitHub Actions
# .github/workflows/security.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:${{ github.sha }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
format: table
exit-code: "1" # Fail the job if vulnerabilities found
severity: "CRITICAL,HIGH"
ignore-unfixed: true # Don't fail on vulns with no fix available
The exit-code: "1" flag makes Trivy fail the job when vulnerabilities matching the severity are found. Remove it to run in audit-only mode (always passes, just reports).
Step 3: Upload Results as SARIF for GitHub Security Tab
GitHub's Code Scanning can display Trivy results in the Security tab — no third-party tool required:
- name: Run Trivy (SARIF output)
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: "CRITICAL,HIGH,MEDIUM"
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
if: always() # Upload even if the scan step failed
After this runs, go to your GitHub repo → Security → Code scanning. You'll see CVEs grouped by severity with links to the package and the CVE description.
Step 4: Scan for Misconfigurations
Trivy also checks Dockerfiles and Kubernetes manifests for security issues:
- name: Scan Dockerfile for misconfigs
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: . # Scan the entire repo
format: table
exit-code: "1"
severity: "HIGH,CRITICAL"
Common findings:
- Running as root (
USER rootwithout switching back) - Using
latesttag - Exposing privileged ports (< 1024)
COPY . .— copies sensitive files like.envinto the image- Kubernetes manifests with
privileged: trueor no resource limits
Step 5: Handle False Positives with .trivyignore
Some CVEs have no fix, are in a library your code doesn't exercise, or are disputed. Create .trivyignore at the root of your repo:
# .trivyignore
# CVE-2023-XXXX: Affects feature X which we don't use
# Expires: 2026-06-01
CVE-2023-XXXX
# This is a build-time dependency only, not in the runtime image
CVE-2024-YYYY
Trivy reads .trivyignore automatically. Add an expiry date comment so you revisit the decision rather than accumulating stale ignores.
For more structured suppression, use a VEX document:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://codingprotocols.com/vex/2026-04-01",
"author": "security@codingprotocols.com",
"timestamp": "2026-04-01T00:00:00Z",
"statements": [
{
"vulnerability": {"name": "CVE-2023-XXXX"},
"products": [{"@id": "pkg:oci/my-app"}],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The vulnerable XML parser is not invoked in our code path"
}
]
}
- name: Scan with VEX
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
vex: vex.json
Step 6: Scan Helm Charts
- name: Scan Helm chart
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: ./charts/my-app
format: table
severity: "HIGH,CRITICAL"
Or the CLI:
trivy config --severity HIGH,CRITICAL charts/my-app/
Trivy understands Helm chart values and templates, rendering them before checking for misconfigurations.
Step 7: Scan a Running Cluster
Trivy has a Kubernetes operator mode that continuously scans workloads:
helm repo add aqua https://aquasecurity.github.io/helm-charts
helm install trivy-operator aqua/trivy-operator \
--namespace trivy-system \
--create-namespace \
--set trivy.ignoreUnfixed=true
# See scan results
kubectl get vulnerabilityreports -A
kubectl describe vulnerabilityreport <name> -n production
The operator creates VulnerabilityReport CRDs for each workload, which you can query with kubectl or view in tools like Lens.
Step 8: Break the Build Correctly
Don't fail on MEDIUM vulnerabilities in the first week — you'll spend all your time triaging instead of shipping. Start with:
severity: "CRITICAL"
ignore-unfixed: true
After a sprint of remediating criticals, expand to HIGH:
severity: "CRITICAL,HIGH"
ignore-unfixed: true
ignore-unfixed: true is essential: many OS package CVEs exist in versions where the upstream hasn't released a fix yet. Failing on those trains developers to click "skip" rather than investigate real issues.
Common Fixes
Update the base image:
# Before
FROM node:18-alpine3.17
# After — use the latest patch
FROM node:18-alpine3.20
Explicitly install a patched version:
RUN apk upgrade --no-cache libssl3
Remove unused packages:
RUN apk add --no-cache curl \
&& apk del curl # Remove after use
Use distroless images — no package manager, no shell, minimal attack surface:
FROM node:18-alpine AS build
# ... build steps ...
FROM gcr.io/distroless/nodejs18-debian12
COPY --from=build /app/dist /app
CMD ["/app/index.js"]
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.