Your pipeline is green. The Docker build finished in ninety seconds, docker push sent the new tag to your registry without a hiccup, and the deployment rolled out before your coffee got cold. Three weeks later, a message lands in the security channel: acme/api:2.4.1 has 45 known vulnerabilities, twelve of them rated CRITICAL. You pull up the base image tag — debian:11-slim — and realize you never wrote a single line of the vulnerable code. It shipped inside the operating system you built on top of. You weren’t careless. You just never had a way to see inside the box before it left the building.
That’s the gap automated image scanning closes, and it’s why this article exists: you’re going to wire Trivy into a GitHub Actions workflow so every image gets X-rayed before it reaches a registry, not after a security audit finds it there first.
Why Reach for Trivy Before Clair or Anchore
Trivy, from Aqua Security, is a single static binary. No database server to stand up, no agent to deploy, no separate policy engine to configure. You run one command and it scans OS packages from Alpine, Debian, Ubuntu, RHEL, and a dozen other distros, plus language-level dependencies — npm, pip, Go modules, Maven, RubyGems, Cargo — in the same pass. It checks Dockerfiles and Kubernetes manifests for misconfigurations too, though this article sticks to image scanning.
Compare that to Clair, which needs a running server backed by PostgreSQL and mostly limits itself to OS-package vulnerabilities — your package.json dependencies are someone else’s problem. Anchore Engine goes further on policy but asks you to run its engine as a persistent service with its own datastore, which is a lot of infrastructure to maintain just to check for CVEs in a build step. Trivy skips all of that. It downloads a vulnerability database, scans, and exits. In a GitHub Actions runner that spins up and dies in minutes, that statelessness isn’t a nice-to-have — it’s the whole reason the integration takes fifteen minutes instead of an afternoon.
Scanning an Image Before You Touch CI
Before automating anything, run Trivy against an image on your own machine so you know what the output actually means. Install it with your package manager of choice — brew install trivy on macOS, or the install script from Trivy’s own docs on Linux — then point it at a public image:
trivy image nginx:latest
The first run downloads the vulnerability database (a few hundred megabytes, cached under ~/.cache/trivy afterward), then prints a table: package name, installed version, vulnerability ID, severity, and the fixed version if one exists. A typical row looks like:
┌──────────┬────────────────┬──────────┬──────────┬───────────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │
├──────────┼────────────────┼──────────┼──────────┼───────────────────┼───────────────┤
│ libssl3 │ CVE-2024-6119 │ HIGH │ fixed │ 3.0.13-1 │ 3.0.15-1 │
└──────────┴────────────────┴──────────┴──────────┴───────────────────┴───────────────┘
Nothing about that command is CI-specific — it’s the same binary, same database, same output format you’ll get in a GitHub Actions job. That matters, because it means you can reproduce a CI failure on your laptop instead of guessing from log output.
Building the GitHub Actions Pipeline
Here’s the scenario: you want to build an image, scan it before it ever reaches your registry, and stop the pipeline cold if a CRITICAL vulnerability shows up. Not HIGH, not MEDIUM — CRITICAL. That threshold is a judgment call, and it’s the right one to start with: fail on everything and you’ll spend Monday morning arguing with your team about a MEDIUM-severity CVE in a transitive dependency nobody can upgrade yet. Fail only on CRITICAL and the gate stays credible.
The key sequencing decision is this: build the image locally with Buildx, load it into the runner’s Docker daemon, and scan that local archive. Don’t push first and scan second — if the scan fails, you don’t want a vulnerable image already sitting in your registry with a tag on it.
name: Build and Scan Container Image
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
# Required for the SARIF upload step later in this workflow.
# Without this, the upload-sarif action fails with a 403.
security-events: write
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image (local only, no push)
uses: docker/build-push-action@v6
with:
context: .
# load: true puts the image into the local Docker daemon so
# Trivy can scan it. We are not pushing to a registry yet.
load: true
push: false
tags: acme/api:${{ github.sha }}
- name: Scan image and fail on CRITICAL vulnerabilities
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: 'acme/api:${{ github.sha }}'
format: 'table'
# Only CRITICAL findings stop the build. HIGH and MEDIUM
# still print in the logs but don't block the merge.
severity: 'CRITICAL'
exit-code: '1'
ignore-unfixed: true
- name: Push image to registry
# This step only runs if the scan above exits 0.
if: success()
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
Notice the push step is split out and guarded with if: success(). If Trivy exits with code 1, GitHub Actions marks the job failed and every later step is skipped by default — the guard is there for readability, not strictly necessary, but it makes the intent obvious to the next engineer reading this file. That’s a small thing, but it’s the difference between a workflow you can explain in one sentence and one you have to trace step by step.
Image Build and Scan Flow
This diagram details the secure sequencing of building an image, loading it locally, and scanning it before it is allowed to reach the registry.
Visual Notes:
- The crucial step is the local
loadphase; the image is scanned while it still only exists on the CI runner. - The SARIF upload can run regardless of failure to provide visibility into the alert.
Getting Results Into the GitHub Security Tab
A red X on a pull request tells you that something failed. It doesn’t tell your team what’s failing across every repo, whether the same CVE keeps showing up, or whether a fix was already merged somewhere else. For that, Trivy can emit SARIF — Static Analysis Results Interchange Format, the same JSON-based standard CodeQL and other GitHub-native scanners use — and GitHub will render it natively in the repository’s Security tab.
Add a second Trivy step that scans everything (not just CRITICAL) without failing the build, and upload the result:
- name: Scan image and generate SARIF report
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: 'acme/api:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
# exit-code 0 here — this step is for visibility, not gating.
# The CRITICAL-only gate above already handles the fail path.
exit-code: '0'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
# if: always() ensures this runs even if an earlier step failed,
# so you still get a report when the build is blocked.
if: always()
with:
sarif_file: 'trivy-results.sarif'
Now every finding — CRITICAL down to MEDIUM — shows up as an alert in Security → Code scanning, deduplicated across runs, with a status you can track and dismiss. The CRITICAL gate stops the merge; the SARIF report gives security and engineering a shared view of everything else that’s accumulating in your images. One honest caveat: SARIF code scanning is a GitHub Advanced Security feature. It’s included free for public repos, but private repos need GHAS enabled — check your org’s billing before you build a workflow around it.
What to Gate On, What to Just Log
A short list, because the temptation after your first successful scan is to fail on everything:
- Do fail builds on CRITICAL (and optionally HIGH, once your team has triaged its current backlog — jumping straight to HIGH on day one usually just trains people to ignore the Actions tab).
- Do scan both the filesystem (
trivy fs .for dependency manifests before you even build) and the final image — a cleanpackage.jsondoesn’t guarantee a clean base image, and vice versa. - Don’t push to your registry before scanning. It sounds obvious written down; it’s the first thing people skip when they’re in a hurry to get a pipeline green.
- Do cache the vulnerability database between runs. Trivy re-downloads it on every job by default, which adds 20-40 seconds you don’t need to pay for twice in the same hour.
actions/cacheagainst~/.cache/trivy(or thecache-dirinput on the action) cuts that down substantially.
When the Pipeline Breaks: Troubleshooting
Every one of these has bitten someone the first week they turned this on.
“context deadline exceeded” or the job hangs on a large image. Trivy’s default timeout is 5 minutes, which is tight for a multi-gigabyte image with a lot of layers. Add timeout: '10m0s' to the action’s with: block.
“Error: GET https://… : 403” or “too many requests” pulling the vulnerability database. The DB ships as an OCI artifact from ghcr.io/aquasecurity/trivy-db, and GitHub’s container registry rate-limits anonymous pulls. If you’re running scans across many repos on a busy runner pool, you’ll eventually hit it. Caching the DB directory between runs (see above) sidesteps most of this, since a cache hit means Trivy never re-pulls it at all.
SARIF upload fails with a 403 and the logs mention “Resource not accessible by integration.” This is almost always the missing security-events: write permission on the job. Add it at the workflow or job level, not just contents: read.
A CVE you can’t fix yet keeps failing the build. A .trivyignore file at your repo root lets you suppress a specific CVE ID by line, optionally with an expiry date so it doesn’t get forgotten. That’s a fine stopgap for a single finding waiting on an upstream patch — it is not a triage strategy. Beating Alert Fatigue: How to Filter and Prioritize Vulnerabilities, later in this series, covers how to manage .trivyignore at scale without it turning into a graveyard of suppressed CVEs nobody remembers adding.
Where This Fits in the Bigger Picture
Image scanning in CI is the gate that catches what earlier checks can’t. Catching Mistakes Early: Setting Up Pre-Commit Hooks for Docker stops obvious mistakes before they’re even committed. Linting Infrastructure as Code: A Deep Dive into Checkov checks the infrastructure config around your containers. Trivy in GitHub Actions checks what actually ends up running inside them — the base image, the OS packages, the language dependencies you pulled in without necessarily reading the changelog. If you want to shrink what Trivy has to report on in the first place, Hardening from the Ground Up: Using Distroless Base Images cuts the attack surface before the scanner even runs. For the full map of how these pieces fit together, see The Ultimate Guide to Shift-Left Security for Docker Containers.
The workflow above is a starting point, not a finished policy. Add it to your primary application repository, watch what the Security tab surfaces in the first week, and adjust the severity threshold from there. Automated scanning only earns its keep if the team trusts the red X enough to act on it — and that trust comes from tuning it to be strict where it matters and quiet everywhere else.
