Integrations

Trivy in GitHub Actions: Scan Docker Images in CI

Published July 9, 2026 · 9 min read

Scanning Docker images in GitHub Actions is the cheapest security control you can add to a container pipeline: every image is checked before it ships, and merges are blocked when new critical CVEs appear. This guide builds the complete workflow two ways — the popular Trivy GitHub Actions step and a deeper-coverage ScanRook job — and explains every step: install, scan, report, gate.

Scanning Docker images in GitHub Actions

Why scan in GitHub Actions at all?

Scanning locally is optional; scanning in CI is structural. Once the scan runs in the same workflow that builds the image, three things become true. Every image that reaches a registry has been scanned — no exceptions for hotfixes or Friday deploys. The scan result is attached to the exact commit and image digest that produced it. And the severity gate turns security policy from a document into a failing check that blocks the merge button.

The pattern below scans the image as a build artifact — export the built image to a tar file and scan that, rather than scanning a running container or a registry copy. It is the same approach we recommend in our general image-scanning guide, adapted to the runner environment.

Scanning with Trivy in GitHub Actions

The most common way teams scan Docker images in GitHub Actions is Aqua Security's official aquasecurity/trivy-action. It wraps the Trivy CLI in a ready-made step: point it at an image reference or a tarball, set a severity threshold, and let exit-code: 1 fail the build when matching findings appear.

      - name: Build container image
        run: docker build -t myapp:ci .

      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:ci
          format: table
          exit-code: "1"
          severity: CRITICAL,HIGH
          ignore-unfixed: true

Trivy in GitHub Actions is fast and effectively zero-config, and it can also emit SARIF to surface findings in GitHub's code-scanning tab via github/codeql-action/upload-sarif. Its tradeoff is the one shared by every single-database scanner: it matches against one aggregated advisory feed, so finding depth is shallower than a multi-source scan. The ScanRook workflow below is the deeper-coverage alternative, and the two run side by side happily — Trivy for fast pull-request feedback, ScanRook on main and nightly for audit-grade coverage.

The complete ScanRook workflow

Save this as .github/workflows/scanrook.yml in your repository. It builds your image, scans it with ScanRook, uploads the JSON report as an artifact, and fails the job on critical or high findings:

name: ScanRook Vulnerability Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 5 * * *"   # nightly: catch advisories published after merge

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install ScanRook
        run: curl -fsSL https://scanrook.sh/install | bash

      - name: Build container image
        run: |
          docker build -t myapp:ci .
          docker save myapp:ci -o myapp.tar

      - name: Cache vulnerability data
        uses: actions/cache@v4
        with:
          path: ~/.scanrook/cache
          key: scanrook-cache-${{ runner.os }}-${{ github.run_number }}
          restore-keys: |
            scanrook-cache-${{ runner.os }}-

      - name: Scan container image
        run: |
          scanrook scan \
            --file ./myapp.tar \
            --mode deep \
            --format json \
            --out report.json
        env:
          NVD_API_KEY: ${{ secrets.NVD_API_KEY }}

      - name: Upload scan report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: scanrook-report
          path: report.json

      - name: Fail on critical or high CVEs
        run: |
          CRITICAL=$(jq '.summary.critical // 0' report.json)
          HIGH=$(jq '.summary.high // 0' report.json)
          echo "Critical: $CRITICAL, High: $HIGH"
          if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
            echo "::error::Found $CRITICAL critical and $HIGH high severity vulnerabilities"
            jq '.findings[] | select(.severity == "CRITICAL" or .severity == "HIGH") | {cve, package: .package.name, version: .package.version, severity}' report.json
            exit 1
          fi

What each step does

  • Triggers.Pull requests catch vulnerabilities you are about to introduce; pushes to main record the state of what shipped; the nightly cron catches the third case people forget — advisories published after your image was built. An image that scanned clean on Monday can legitimately fail on Thursday with zero code changes.
  • Install ScanRook.The shell installer detects the platform and drops the release binary into the runner's PATH. Runners are ephemeral, so this runs every time — it is a small download and keeps you on the current release.
  • Build and save. ScanRook scans saved image archives, so docker save exports the freshly built image to a tar. Scanning the artifact you just built (rather than re-pulling from a registry) guarantees you scanned exactly what the workflow produced.
  • Cache vulnerability data. actions/cache persists ScanRook's cache directory between runs, so repeated scans skip re-fetching advisory data they already have. The restore-keys fallback means even a partial cache hit saves time.
  • Scan. --mode deep is the thorough profile; switch pull requests to --mode light if you want faster PR feedback and keep deep mode for main and the nightly run. The optional NVD_API_KEY secret raises NVD rate limits during enrichment.
  • Upload the report. if: always() ensures the JSON report is preserved as a build artifact even when the gate step fails — which is precisely when you most want to read it.
  • Gate. The last step reads severity totals with jq and exits non-zero past your threshold, failing the check. More on tuning this below.

Failing the build on severity — without hating your life

A gate that is too strict gets disabled within a month; a gate that is too loose is theater. Three adjustments make the difference:

Start with critical-only. Change the condition to if [ "$CRITICAL" -gt 0 ] and let high-severity findings warn without blocking. Once the backlog is at zero, tighten to include highs. A base image inherited from upstream can carry hundreds of findings you did not introduce — see how to reduce CVEs in Docker images for bringing that number down before you tighten the gate.

Gate on new findings, not all findings.Store the report from the last main build as an artifact and compare: fail the PR only if it introduces CVEs that main does not already have. This keeps the gate actionable — the developer who sees the failure is always the developer who caused it.

Make the check required.A failing job only blocks a merge if the workflow is listed as a required status check in your branch protection rules (Settings → Branches → require status checks). Without that, the red X is advisory.

Scanning images you did not build

The workflow above scans an image built in the same job, but the pattern extends to images that already exist — a base image you are evaluating, a vendor image, or the currently deployed tag. Pull it, save it, scan the tar:

      - name: Scan the deployed production image
        run: |
          docker pull registry.example.com/myapp:prod
          docker save registry.example.com/myapp:prod -o deployed.tar
          scanrook scan --file deployed.tar --format json --out deployed.json

Running this in the nightly job alongside the fresh build gives you two reports to compare: what you are about to ship versus what is currently running. A widening gap between the two is your signal that a redeploy is overdue even though nothing in the repository changed.

Operational tips

  • Store NVD_API_KEY as a repository or organization secret, never in the workflow file.
  • Pin action versions (actions/checkout@v4) and consider pinning by commit SHA for supply-chain-sensitive repositories.
  • For monorepos building several images, run the scan as a matrix job — one scan per image, each with its own report artifact and gate.
  • Keep reports from main-branch runs; a history of JSON reports is a free audit trail of what shipped with what findings, and several compliance frameworks ask for exactly that.

The maintained reference version of this workflow, including an SBOM-diff variant that gates on newly added packages, lives in the ScanRook docs.

Failure-mode checklist: what silently breaks a scanning workflow

The workflow above is correct, and it will still stop protecting you if one of the GitHub Actions platform behaviours below catches you out. None of these produce a red X — they produce a green check on a scan that did not really happen, which is worse. Walk this list once after you merge the workflow.

Triggers and permissions

  • Scheduled workflows are disabled automatically after a long period of repository inactivity. On a low-traffic repo your nightly scan stops running and nothing tells you. Check the Actions tab periodically, or have the schedule job report success to somewhere you actually watch.
  • Secrets are not available to workflow runs triggered by pull requests from forks. Your NVD_API_KEY silently resolves to an empty string, enrichment degrades, and the report still looks fine. Handle the empty case explicitly rather than assuming the secret is present.
  • If you upload results to GitHub code scanning, the job needs the security-events write permission. The top-level permissions block in the workflow above grants contents: read only.
    permissions:
      contents: read
      security-events: write
  • A failing job blocks a merge only when the workflow is a required status check. Confirm it in branch protection after the first successful run, because the check name has to exist before you can require it.

Runner and caching behaviour

  • Hosted runners have a finite disk. docker build plus docker save writes the image twice, and a large image can exhaust the runner mid-job. Free space before the save step, or scan straight from the registry, if you hit this.
  • Give the job a timeout so a hung network call during enrichment cannot sit there consuming your Actions minutes until the platform maximum.
    jobs:
      scan:
        runs-on: ubuntu-latest
        timeout-minutes: 20
  • Cache scope is directional: a branch can restore caches created on its base branch, but a cache created on a PR branch is not visible to other branches. Expect the first run on a new branch to be a cold scan, and do not treat the slower time as a regression.
  • Add a concurrency group so a rapid series of pushes cancels superseded scans instead of queueing five runs of the same image.
    concurrency:
      group: scan-${{ github.ref }}
      cancel-in-progress: true
  • If you build for a different architecture than the runner, build and save that platform explicitly. Scanning an amd64 build of an image you deploy as arm64 audits packages you do not ship.
    docker build --platform linux/arm64 -t myapp:ci .

Making the result visible and durable

  • Write the summary into the job summary so a developer sees the counts on the run page without downloading and opening the JSON artifact. This single change does more for adoption than any gate tuning.
    echo "### Scan summary" >> "$GITHUB_STEP_SUMMARY"
    jq -r '.summary | to_entries[] | "- \(.key): \(.value)"' report.json >> "$GITHUB_STEP_SUMMARY"
  • Artifacts expire on your repository retention setting. If you are keeping reports as a compliance trail, copy them somewhere durable in the same job rather than relying on artifact storage.
  • Guard the gate step against a missing or empty report. If the scan step failed early, jq returns nothing, the comparison is against an empty string, and the step can pass by accident. Fail explicitly when the report is absent.
    test -s report.json || { echo "::error::no scan report produced"; exit 1; }
  • Fail the job when the report contains no packages at all. A parse failure and a genuinely clean image both produce zero findings, and only one of them is good news.

Failure modes specific to running container scans on GitHub Actions. Structural guidance only — no thresholds, timings, or quota figures are asserted here; check the current GitHub Actions documentation for the limits that apply to your plan.

Frequently asked questions

How do I scan a Docker image in GitHub Actions?

Build the image, export it with docker save, install the scanner CLI, and scan the tar file. A final jq step reads the JSON report and fails the job past your severity threshold.

Should scans run on PRs or on a schedule?

Both. PR scans catch what you are introducing; scheduled scans catch advisories published after merge. Images go stale without changing.

How do I fail the build on critical CVEs?

Read severity counts from the JSON report with jq and exit 1 when they exceed the threshold. Make the workflow a required status check so the failure actually blocks merges.

Will scanning slow down CI?

A little — and caching the scanner's data directory plus a lighter scan mode on PRs keeps it to a small fraction of the image build time.

Gate your pipeline with ScanRook

Drop the workflow above into your repository and every image you build is checked against OSV, NVD, and vendor advisory data before it ships — with JSON reports you can gate on, archive, and diff between builds.

Related Posts

More on this topic.