DevOps & Platform

Scanning Container Images for CVEs with Trivy in CI

Beginner20 min to complete8 min readApril 22, 2026Updated August 19, 2026

Quick answer

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.

beginner · 20 min

Before you begin

  • A GitHub Actions workflow that builds Docker images
  • Basic Docker knowledge
Trivy
Security
CI/CD
Container Security
DevSecOps

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:

bash
1# Install Trivy
2brew install trivy   # macOS
3# Or:
4curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.50.0
5
6# Scan a local image
7trivy image nginx:1.25
8
9# Scan with severity filter
10trivy image --severity HIGH,CRITICAL nginx:1.25
11
12# Output as JSON for further processing
13trivy image --format json --output results.json nginx:1.25
14
15# Scan your own built image
16docker build -t my-app:local .
17trivy 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

yaml
1# .github/workflows/security.yml
2name: Security Scan
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10jobs:
11  scan:
12    runs-on: ubuntu-latest
13
14    steps:
15      - name: Checkout
16        uses: actions/checkout@v4
17
18      - name: Build image
19        run: docker build -t my-app:${{ github.sha }} .
20
21      - name: Scan image with Trivy
22        uses: aquasecurity/[email protected]
23        with:
24          image-ref: my-app:${{ github.sha }}
25          format: table
26          exit-code: "1"          # Fail the job if vulnerabilities found
27          severity: "CRITICAL,HIGH"
28          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:

yaml
1      - name: Run Trivy (SARIF output)
2        uses: aquasecurity/[email protected]
3        with:
4          image-ref: my-app:${{ github.sha }}
5          format: sarif
6          output: trivy-results.sarif
7          severity: "CRITICAL,HIGH,MEDIUM"
8
9      - name: Upload SARIF to GitHub Security
10        uses: github/codeql-action/upload-sarif@v3
11        with:
12          sarif_file: trivy-results.sarif
13        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:

yaml
1      - name: Scan Dockerfile for misconfigs
2        uses: aquasecurity/[email protected]
3        with:
4          scan-type: config
5          scan-ref: .           # Scan the entire repo
6          format: table
7          exit-code: "1"
8          severity: "HIGH,CRITICAL"

Common findings:

  • Running as root (USER root without switching back)
  • Using latest tag
  • Exposing privileged ports (< 1024)
  • COPY . . — copies sensitive files like .env into the image
  • Kubernetes manifests with privileged: true or 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:

json
1{
2  "@context": "https://openvex.dev/ns/v0.2.0",
3  "@id": "https://codingprotocols.com/vex/2026-04-01",
4  "author": "[email protected]",
5  "timestamp": "2026-04-01T00:00:00Z",
6  "statements": [
7    {
8      "vulnerability": {"name": "CVE-2023-XXXX"},
9      "products": [{"@id": "pkg:oci/my-app"}],
10      "status": "not_affected",
11      "justification": "vulnerable_code_not_in_execute_path",
12      "impact_statement": "The vulnerable XML parser is not invoked in our code path"
13    }
14  ]
15}

VEX suppression is not available as an input to the trivy-action GitHub Action. To use VEX in CI, call the Trivy CLI directly:

yaml
1      - name: Scan with VEX
2        run: |
3          trivy image \
4            --vex vex.json \
5            --exit-code 1 \
6            --severity CRITICAL,HIGH \
7            my-app:${{ github.sha }}

Step 6: Scan Helm Charts

yaml
1      - name: Scan Helm chart
2        uses: aquasecurity/[email protected]
3        with:
4          scan-type: config
5          scan-ref: ./charts/my-app
6          format: table
7          severity: "HIGH,CRITICAL"

Or the CLI:

bash
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:

bash
1helm repo add aqua https://aquasecurity.github.io/helm-charts
2helm install trivy-operator aqua/trivy-operator \
3  --namespace trivy-system \
4  --create-namespace \
5  --set trivy.ignoreUnfixed=true
6
7# See scan results
8kubectl get vulnerabilityreports -A
9kubectl 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:

yaml
severity: "CRITICAL"
ignore-unfixed: true

After a sprint of remediating criticals, expand to HIGH:

yaml
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:

dockerfile
# Before
FROM node:18-alpine3.17

# After — use the latest patch
FROM node:18-alpine3.20

Explicitly install a patched version:

dockerfile
RUN apk upgrade --no-cache libssl3

Remove unused packages:

dockerfile
RUN apk add --no-cache curl \
    && apk del curl   # Remove after use

Use distroless images — no package manager, no shell, minimal attack surface:

dockerfile
FROM node:18-alpine AS build
# ... build steps ...

FROM gcr.io/distroless/nodejs18-debian12
COPY --from=build /app/dist /app
CMD ["/app/index.js"]

Frequently Asked Questions

Should a CVE finding fail the build?

Fail on severity you have agreed to act on, and only for vulnerabilities with a fix available. Failing on every finding including unfixable ones trains people to bypass the gate, which is worse than no gate. Start by failing on critical with a fix, and tighten once the backlog is under control.

How do I handle a vulnerability with no patch?

Record it in an ignore file with an expiry and a reason, so it resurfaces for review rather than disappearing. An ignore entry without a date is a permanent exemption nobody revisits. If the finding is in a component you do not use, that is worth noting explicitly — it is still exposure if an attacker can reach it.

Why do scan results differ between my laptop and CI?

The vulnerability database changes constantly, so two scans minutes apart can differ. Cache the database in CI for speed but let it refresh regularly, and expect new findings on unchanged images — that is the scanner working, not a false alarm. Pinning the database to avoid surprises means not learning about new vulnerabilities.

Should I scan the image or the filesystem?

Both catch different things. Scanning the built image reflects what actually ships, including base image layers. Scanning the repository catches dependency manifests before an image exists, which gives a faster signal in pull requests. Running the manifest scan on pull requests and the image scan before push is a reasonable split.

Official References

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.