Container Image Scanning with Trivy: Complete Setup Guide
This Is Your Attack Surface. Let's Shrink It.
Every container image you deploy is a bundle of dependencies, and every dependency is a potential vulnerability. The average container image has 40-70 known vulnerabilities. Most teams don't scan. Most breaches exploit known, patchable CVEs.
Trivy scans container images, filesystems, Git repos, and Kubernetes clusters. It's open-source, fast, and the barrier to entry is zero. There's no excuse not to scan.
Prerequisites
- Docker installed
- A container image to scan (we'll use examples)
- 5 minutes
Quick Start: Your First Scan
Install Trivy
# Ubuntu/Debian
sudo apt-get install wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | \
gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | \
sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivy
# macOS
brew install trivy
# Docker (no install needed)
docker run aquasec/trivy image your-image:tag
Scan an Image
trivy image nginx:1.25
Output shows vulnerabilities grouped by severity:
nginx:1.25 (debian 12.4)
Total: 142 (UNKNOWN: 0, LOW: 85, MEDIUM: 42, HIGH: 12, CRITICAL: 3)
┌───────────────┬──────────────────┬──────────┬────────────────┐
│ Library │ Vulnerability │ Severity │ Fixed Version │
├───────────────┼──────────────────┼──────────┼────────────────┤
│ libssl3 │ CVE-2024-0567 │ CRITICAL │ 3.0.13-1 │
│ libcurl4 │ CVE-2024-2398 │ HIGH │ 7.88.1-10+deb12│
│ zlib1g │ CVE-2023-45853 │ HIGH │ 1:1.2.13+dfsg-1│
└───────────────┴──────────────────┴──────────┴────────────────┘
Three critical CVEs in a standard nginx image. This is your attack surface.
CI/CD Integration
GitHub Actions
name: Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: 1 fails the pipeline on findings. severity: CRITICAL,HIGH focuses on what matters. ignore-unfixed: true skips vulnerabilities with no available fix — you can't patch what doesn't have a patch.
Block Deployments on Critical CVEs
- name: Trivy scan (SARIF)
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL
- name: Upload to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
This uploads results to GitHub's Security tab, giving you vulnerability tracking alongside your code.
Fixing Vulnerabilities
Strategy 1: Update Base Image
# Before: vulnerable base
FROM node:18-bullseye
# After: minimal base with fewer packages
FROM node:18-alpine
Alpine images typically have 80-90% fewer vulnerabilities than Debian-based images because they have fewer packages installed.
Strategy 2: Multi-Stage Builds
# Build stage — has dev dependencies (vulnerabilities don't matter here)
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage — minimal attack surface
FROM node:22-alpine AS production
WORKDIR /app
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
The production image only contains what's needed to run. Build tools, compilers, and dev dependencies stay in the build stage.
GitLab CI Integration
Not on GitHub? Here's the GitLab equivalent:
stages:
- build
- scan
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
trivy-scan:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
needs: ["build"]
script:
- trivy image
--exit-code 1
--severity CRITICAL,HIGH
--ignore-unfixed
--format template
--template "@/contrib/gitlab.tpl"
--output gl-container-scanning-report.json
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
artifacts:
reports:
container_scanning: gl-container-scanning-report.json
allow_failure: false
The gitlab.tpl template formats results for GitLab's Security Dashboard. Findings show up directly in merge requests.
Scanning Beyond Container Images
Trivy scans more than images. Use it to catch vulnerabilities in your codebase before they reach a container.
Filesystem Scanning
# Scan your project directory for dependency vulnerabilities
trivy fs --severity HIGH,CRITICAL .
# Scan a specific lockfile
trivy fs --scanners vuln package-lock.json
This catches vulnerable npm packages, Python dependencies, Go modules, and more without building a container first. Run this in pre-commit hooks for instant feedback.
Infrastructure as Code Scanning
# Scan Terraform files for misconfigurations
trivy config ./terraform/
# Scan Kubernetes manifests
trivy config ./k8s/
# Scan Dockerfiles for best-practice violations
trivy config --policy-namespaces builtin.dockerfile .
Output looks like this:
Dockerfile (dockerfile)
Tests: 27 (SUCCESSES: 22, FAILURES: 5)
Failures: 5
LOW: Specify a tag in the 'FROM' statement
════════════════════════════════════════════
Use a specific version tag instead of 'latest' to ensure reproducibility.
MEDIUM: 'RUN' instruction using 'sudo'
════════════════════════════════════════════
Avoid using 'sudo' as it may grant unexpected permissions.
Secret Scanning
# Scan for accidentally committed secrets
trivy fs --scanners secret .
This catches AWS keys, GitHub tokens, database passwords, and other credentials that slipped into your codebase. Run it before every push.
Scanning Kubernetes Clusters
Trivy can scan a running cluster for vulnerabilities in deployed images and misconfigured workloads:
# Scan all images in the cluster
trivy k8s --report summary cluster
# Scan a specific namespace
trivy k8s --namespace production --report all
# Generate a compliance report
trivy k8s --compliance k8s-nsa --report summary cluster
The compliance report checks your cluster against NSA/CISA Kubernetes hardening guidelines. It catches things like containers running as root, missing resource limits, and privileged pods.
Caching Trivy's Database in CI
First scan downloads the vulnerability DB. In CI, that's wasted time on every run.
GitHub Actions Cache
- name: Cache Trivy DB
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: trivy-db-${{ github.run_id }}
restore-keys: |
trivy-db-
- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
Self-Hosted DB Mirror
For air-gapped environments or when GitHub rate-limits your CI:
# Download the DB once, serve it internally
trivy image --download-db-only --db-repository ghcr.io/aquasecurity/trivy-db
# Point Trivy at your mirror
trivy image --db-repository your-registry/trivy-db myapp:latest
Building a Vulnerability Management Workflow
Scanning is step one. You need a process for handling findings.
Triage within 24 hours. Every CRITICAL finding gets a ticket the day it appears. Assign it to the team that owns the service.
Fix or accept within 7 days. Either patch the vulnerability or document why it's accepted risk. Undocumented ignores are time bombs.
Review .trivyignore monthly. CVEs that had no fix last month might have one now. Set a calendar reminder.
Track metrics over time. Count open CRITICAL/HIGH vulnerabilities per service per week. The trend matters more than the absolute number.
# Generate a JSON report for metrics collection
trivy image --format json --output scan-results.json myapp:latest
# Extract counts by severity with jq
jq '[.Results[].Vulnerabilities[] | .Severity] | group_by(.) | map({(.[0]): length}) | add' scan-results.json
Feed this into your monitoring stack. A dashboard showing vulnerability counts per service over time creates accountability.
Troubleshooting
Problem: Trivy is slow on first run.
Fix: First run downloads the vulnerability database (~30MB). Subsequent runs use cache. In CI, cache ~/.cache/trivy/ between runs.
Problem: Too many LOW/MEDIUM findings cluttering output.
Fix: Use --severity CRITICAL,HIGH to focus on actionable items first.
Problem: False positive on a CVE.
Fix: Create a .trivyignore file:
# Accepted risk: not exploitable in our context
# Reviewed: 2026-03-20 by @amara
# Reason: Library not used in network-facing code path
CVE-2024-0567
# Expires: 2026-06-20 (re-evaluate quarterly)
CVE-2024-1234
Document WHY each ignore exists, WHO approved it, and WHEN it should be re-evaluated. Security debt compounds faster than technical debt.
Problem: Trivy reports vulnerabilities in build-stage layers.
Fix: Only scan the final stage of multi-stage builds. Use docker build --target production to build just the production stage, then scan that.
Problem: Different results between local scan and CI.
Fix: Ensure both use the same Trivy version and DB. Pin the Trivy version in CI and use trivy --version locally to verify they match.
Security Checklist
- Trivy installed and running locally
- CI pipeline blocks on CRITICAL/HIGH vulnerabilities
- Base images use Alpine or distroless variants
- Multi-stage builds separate build from runtime
-
.trivyignoreentries are documented with risk acceptance - Scanning runs on every PR, not just main branch
- Results uploaded to GitHub Security or equivalent dashboard
- Monthly review of ignored vulnerabilities
Assume breach. Now what? If you're scanning every image before it reaches production, you've already shrunk your attack surface by orders of magnitude.
Integrating Trivy into a Complete Security Pipeline
Scanning is one layer. A mature security pipeline chains multiple checks:
name: Security Pipeline
on:
push:
branches: [main]
pull_request:
jobs:
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan dependencies
run: trivy fs --scanners vuln --severity HIGH,CRITICAL --exit-code 1 .
iac-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan IaC
run: trivy config --severity HIGH,CRITICAL --exit-code 1 .
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan for secrets
run: trivy fs --scanners secret --exit-code 1 .
image-scan:
needs: [dependency-scan, iac-scan, secret-scan]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
Dependencies, infrastructure code, secrets, and container images — all scanned in parallel. The image scan only runs after the other checks pass. Shift left at every layer.
Related Articles
DevSecOps Lead
Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.
Related Articles
Kubernetes Security Hardening for Production: The Complete Guide
Harden Kubernetes clusters for production with RBAC, network policies, pod security standards, secrets management, and admission controllers.
Automated Dependency Vulnerability Scanning in CI: Stop Shipping Known CVEs
Add automated dependency vulnerability scanning to your CI pipeline using Trivy and Grype. Catch known CVEs before they hit production.
HashiCorp Vault and Kubernetes: Secrets Management That Actually Works
Integrate HashiCorp Vault with Kubernetes to eliminate static secrets from your cluster — with working manifests, threat models, and pipeline automation.