Scan Container Images with Trivy

Trivy is Aqua Security's open-source vulnerability scanner. It can scan container images, filesystems, IaC configs, and Kubernetes clusters for known CVEs and misconfigurations.

This guide shows how to run Trivy from an RWX run to scan container images. The pattern works in CI on every push, but it's especially useful as a scheduled job that scans your production images daily so a freshly disclosed CVE in something you already shipped surfaces quickly.

Install Trivy

Install Trivy in its own task so the install is cached and reused across runs:

- key: trivy-install
  run: |
    curl -fSLO https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.deb
    sudo dpkg -i trivy_0.69.3_Linux-64bit.deb
    rm trivy_0.69.3_Linux-64bit.deb

For arm64 base images, swap the .deb for trivy_0.69.3_Linux-ARM64.deb.

Cache the vulnerability database

Trivy downloads its vulnerability database (and Java DB, if applicable) on every scan, which is slow and can be rate-limited by GitHub Container Registry. Pull the DBs in a separate task with a TTL'd cache so they're refreshed periodically without being downloaded on every run:

- key: trivy
  use: trivy-install
  run: |
    trivy image --download-db-only
    trivy image --download-java-db-only
  cache:
    ttl: 12 hours

Tasks that depend on trivy will inherit the cached database via the merged filesystem.

Scan an image

To scan an image from a registry, pull it with Docker and run trivy image:

- key: scan-image
  docker: true
  use: trivy
  run: |
    docker pull "$IMAGE_TAG"
    trivy image \
      --scanners vuln \
      --severity CRITICAL \
      --exit-code 1 \
      "$IMAGE_TAG"
  env:
    IMAGE_TAG: my-registry.example.com/my-app:latest
  cache: false

A few things worth noting:

  • docker: true starts a Docker daemon for the task so docker pull works.
  • --scanners vuln limits the scan to vulnerabilities. Trivy also scans for secrets and misconfigurations by default — drop the flag if you want those too.
  • --severity CRITICAL filters down to critical-only findings. Use HIGH,CRITICAL to widen the gate, or omit it to report everything.
  • --exit-code 1 makes Trivy fail the task when it finds a matching finding. Without it, the task succeeds even when vulnerabilities are reported.
  • cache: false ensures the scan actually runs each time (necessary only if the image is mutable and can change between scans). With caching enabled, identical inputs (image tag, command) would skip the scan, which defeats the point of scanning against a fresh database.

Suppressing known issues

Use --ignorefile to suppress findings you've already triaged. Pair it with --show-suppressed to keep them visible in the output:

run: |
  docker pull "$IMAGE_TAG"
  trivy image \
    --scanners vuln \
    --severity CRITICAL \
    --ignorefile ${{ run.dir }}/support/.trivyignore \
    --show-suppressed \
    --exit-code 1 \
    "$IMAGE_TAG"

Commit support/.trivyignore alongside your run definition so the suppressions live with the code.

Scan production images on a schedule

The most common pattern is scanning a production image daily. Use a cron schedule to drive it, and pull the image from your registry — the example below uses ECR:

on:
  cron:
    - key: scan-image
      schedule: '0 7 * * * America/New_York' # every day at 7am ET
      target: scan-image
      title: 'Daily image scan'
      init:
        aws-role: arn:aws:iam::123456789012:role/your-role
        registry-host: 123456789012.dkr.ecr.us-east-2.amazonaws.com
        remote-tag: 123456789012.dkr.ecr.us-east-2.amazonaws.com/your-app:latest

base:
  image: ubuntu:24.04
  config: rwx/base 1.0.3

tasks:
  - key: aws-cli
    call: aws/install-cli 1.0.11

  - key: aws
    use: aws-cli
    call: aws/assume-role 2.0.9
    with:
      region: us-east-2
      role-to-assume: ${{ init.aws-role }}
      role-duration-seconds: 900

  - key: ecr
    use: aws
    run: |
      cat <<EOF > $RWX_HOOKS_BEFORE_TASK/ecr.sh
      aws ecr get-login-password --region us-east-2 \
        | docker login --username AWS --password-stdin $REGISTRY_HOST
      EOF
      chmod +x $RWX_HOOKS_BEFORE_TASK/ecr.sh
    env:
      AWS_SKIP_AUTH: true
      REGISTRY_HOST: ${{ init.registry-host }}

  - key: trivy-install
    run: |
      curl -fSLO https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.deb
      sudo dpkg -i trivy_0.69.3_Linux-64bit.deb
      rm trivy_0.69.3_Linux-64bit.deb

  - key: trivy
    use: trivy-install
    run: |
      trivy image --download-db-only
      trivy image --download-java-db-only
    cache:
      ttl: 12 hours

  - key: scan-image
    docker: true
    use: [trivy, ecr]
    run: |
      docker pull "$IMAGE_TAG"
      trivy image \
        --scanners vuln \
        --severity CRITICAL \
        --exit-code 1 \
        --show-suppressed \
        "$IMAGE_TAG"
    env:
      AWS_OIDC_TOKEN:
        value: ${{ vaults.default.oidc.aws }}
        cache-key: excluded
      IMAGE_TAG: ${{ init.remote-tag }}
    cache: false
    outputs:
      filesystem: false

A few things worth noting:

  • Writing the ECR docker login into $RWX_HOOKS_BEFORE_TASK on the ecr task means downstream tasks re-authenticate at the start of each task they run in, which keeps short-lived AWS credentials from expiring mid-run.
  • AWS_OIDC_TOKEN is marked cache-key: excluded so the rotating token doesn't bust the task cache.
  • outputs.filesystem: false skips uploading the task filesystem — there's nothing downstream consumes it.
  • A cron-driven failure surfaces in RWX as a failed run on the schedule. Wire up notifications so a fresh CVE actually pages someone.

Reference