DevOpsil
Security
90%
Fresh

Container Image Scanning with Trivy: Complete Setup Guide

Amara OkaforAmara Okafor8 min read

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 --from=builder /app/dist ./dist
COPY --from=builder /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
  • .trivyignore entries 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.

Share:
Amara Okafor
Amara Okafor

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