You push a container image to Amazon ECR. Is it safe to run?

There are two common ways to answer that on AWS. You can use AWS's own scanning (ECR basic scanning, or Amazon Inspector), or an open-source scanner like Trivy running in your CI. This post explains both from scratch, compares them, and shows a practical Trivy setup in GitHub Actions. That setup also covers the one thing people usually forget: images that are already running.

No prior Trivy knowledge needed.

What is Trivy?

Trivy is an open-source security scanner from Aqua Security. It's a single binary that you point at a target, and it reports problems.

Targets it can scan:

  • container images (trivy image)
  • a local folder or a git repository (trivy fs, trivy repo)
  • Infrastructure as Code: Terraform, CloudFormation, Dockerfiles, Kubernetes manifests (trivy config)
  • a Kubernetes cluster (trivy k8s)
  • an existing SBOM (trivy sbom)

What it looks for (called scanners):

  • vuln: known CVEs in OS packages (apk, apt, rpm…) and language packages (pip, npm, Go modules, Maven, Cargo…)
  • misconfig: insecure settings, for example a security group open to 0.0.0.0/0 or a container running as root
  • secret: API keys and passwords left in files or image layers
  • license: licenses of your dependencies

How it works

Trivy keeps a vulnerability database built from public sources (NVD, GitHub advisories, distro security trackers and others). The database is updated several times a day. On each run, Trivy:

  1. downloads the latest database (or uses a cached copy),
  2. inventories what's inside the target: which OS, which packages and versions,
  3. matches that inventory against the database.

Nothing is uploaded anywhere. Your image never leaves your machine or runner.

Your first scan

# macOS brew install trivy # Scan a public image trivy image python:3.12-slim 

The output is a table per layer type: OS packages first, then language packages. Each row shows the package, installed version, CVE ID, severity, and fixed version. The fixed version is the most useful column, because it tells you whether upgrading helps at all.

A few flags you'll use constantly:

# Only what matters, and only what you can actually fix today trivy image --severity HIGH,CRITICAL --ignore-unfixed python:3.12-slim # Fail the command (exit code 1) if anything is found: this is your CI gate trivy image --severity CRITICAL --exit-code 1 myapp:latest # Machine-readable output trivy image --format json --output result.json myapp:latest trivy image --format sarif --output result.sarif myapp:latest # Generate an SBOM (list of everything inside the image) trivy image --format cyclonedx --output sbom.json myapp:latest 

To accept a risk on purpose, list the CVE in a .trivyignore file next to where you run Trivy:

# Not reachable in our usage; revisit 2027-01-01 CVE-2024-12345 

Scanning a private ECR image works with normal AWS credentials. Trivy uses them to authenticate to ECR:

aws sso login --profile dev AWS_PROFILE=dev trivy image 123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.4.0 

What AWS gives you

ECR basic scanning

This is free and built into ECR. You turn on scan on push (or trigger scans manually), and findings appear in the ECR console next to the image. Its limits:

  • It covers OS packages only, not your Python or Node dependencies.
  • An image is scanned when it's pushed, and not again unless you trigger it.

ECR enhanced scanning (Amazon Inspector)

When you switch ECR to enhanced scanning, Amazon Inspector takes over. It adds:

  • language package scanning on top of OS packages,
  • continuous scanning: when a new CVE is published, Inspector re-evaluates images it already scanned. You choose how long images stay monitored after their last push or pull.
  • findings in the Inspector console, Security Hub, and EventBridge (so you can route them to Slack, Jira, and so on).

Amazon Inspector beyond containers

Inspector also scans EC2 instances (agent-based through SSM, or agentless through EBS snapshots) and Lambda functions. It adds an Inspector score: it adjusts the CVSS score using context such as network reachability, so a critical CVE on a private, unreachable instance ranks lower than the same CVE behind a public load balancer.

Pricing is per resource. For ECR it's a fee per image on its first scan plus a smaller fee for each rescan. For EC2 it's per instance per month. Check the Inspector pricing page for your region. A detail that's easy to miss: with Auto Scaling groups, every instance that lives for a while counts, so bills can grow faster than you expect.

The key difference: when the scan happens

Trivy is point-in-time: it scans when you run it. Inspector is continuous: it rescans when the world changes.

This matters more than any feature list. Picture this:

  1. Monday: you build myapp:1.4.0. Trivy says it's clean. You deploy it.
  2. Wednesday: a critical CVE is published for a library inside that image.
  3. Nobody rebuilds myapp:1.4.0, because nothing changed in your code.

Inspector would flag it on Wednesday. Trivy in CI never looks at it again.

So if you use Trivy instead of Inspector, you have to schedule rescans yourself. The good news is that it's a few lines of YAML. We'll get there in a moment.

Side by side

ECR basic Inspector (ECR enhanced) Trivy
Cost Free Per image scan + per rescan Free (open source)
OS packages ✅ ✅ ✅
Language packages ❌ ✅ (check supported ecosystems) ✅ (broad support)
Rescans on new CVEs ❌ ✅ automatic Only if you schedule it
Where it runs ECR AWS-managed Anywhere: laptop, CI, cluster
Scan before push / fail the build ❌ Possible with Inspector's CI/CD scanning API ✅ native (--exit-code)
Where results go ECR console Inspector, Security Hub, EventBridge Wherever you send them: terminal, JSON, SARIF, GitHub
IaC, secrets, licenses ❌ Partly (Lambda code scanning) ✅
EC2 / Lambda ❌ ✅ ❌ (not as a managed service)
Context-aware scoring ❌ ✅ ❌ (plain severity)
Works outside AWS ❌ ❌ ✅

A practical Trivy setup in GitHub Actions

Here are two workflows: one that scans before an image ships, and one that rescans what's running every day.

1. Scan in CI, before pushing to ECR

name: build on: push: branches: [main] permissions: contents: read id-token: write # OIDC to AWS, no access keys jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Build image run: docker build -t myapp:${{ github.sha }} . - name: Scan image uses: aquasecurity/trivy-action@<pinned-sha> # pin to a commit SHA with: image-ref: myapp:${{ github.sha }} severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1" # fail the job, so the push below never runs - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push aws-region: eu-central-1 - id: ecr uses: aws-actions/amazon-ecr-login@v2 - name: Push run: | IMAGE=${{ steps.ecr.outputs.registry }}/myapp:${{ github.sha }} docker tag myapp:${{ github.sha }} "$IMAGE" docker push "$IMAGE" 

A high or critical CVE that already has a fix fails the job, so the image never reaches ECR. The findings table is printed in the job log, right where the developer is already looking.

ignore-unfixed keeps the gate fair: a CVE with no fixed version yet would block every build without giving you anything to do about it.

There's no issue here on purpose: the person who pushed sees the failure right away, and the vulnerable image never ships. Issues are for problems that are already running in production, which is what the next workflow handles.

Gate or report? This example is the strictest option: it blocks the push. Many teams don't want that. When a new CVE lands in a common base image, every build can turn red on the same day, and people start adding everything to .trivyignore just to ship. The softer alternative is to push first, scan without stopping the build, and open an issue for what's found. To do that, add continue-on-error: true to the scan step and reuse the issue step from the next workflow with if: steps.scan.outcome == 'failure'. A middle ground is to gate only on CRITICAL and track everything else in issues. Pick what fits your team.

2. Daily scan of the images actually running in ECS

This is the part that replaces Inspector's continuous scanning. Instead of rescanning every image in ECR (most of them are old builds nobody runs), ask ECS which images are running right now and scan only those.

name: scan-running-images on: schedule: - cron: "0 6 * * *" # every day workflow_dispatch: permissions: contents: read id-token: write issues: write # open an issue when a running image is vulnerable jobs: list: runs-on: ubuntu-latest outputs: images: ${{ steps.list.outputs.images }} steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecs-read aws-region: eu-central-1 - id: list name: List images of running ECS tasks run: | for cluster in $(aws ecs list-clusters --query 'clusterArns[]' --output text); do tasks=$(aws ecs list-tasks --cluster "$cluster" --desired-status RUNNING \ --query 'taskArns[]' --output text) [ -n "$tasks" ] || continue # Unquoted on purpose: one argument per task ARN (max 100 per call) # shellcheck disable=SC2086 aws ecs describe-tasks --cluster "$cluster" --tasks $tasks \ --query 'tasks[].containers[].image' --output text done | tr '\t' '\n' | { grep '\.dkr\.ecr\.' || true; } | sort -u > images.txt echo "images=$(jq -Rsc 'split("\n") | map(select(length > 0))' images.txt)" >> "$GITHUB_OUTPUT" cat images.txt scan: needs: list if: needs.list.outputs.images != '[]' runs-on: ubuntu-latest strategy: fail-fast: false matrix: image: ${{ fromJson(needs.list.outputs.images) }} steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecs-read aws-region: eu-central-1 - uses: aws-actions/amazon-ecr-login@v2 - name: Scan id: scan uses: aquasecurity/trivy-action@<pinned-sha> with: image-ref: ${{ matrix.image }} severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1" - name: Open an issue (once per image) if: failure() && steps.scan.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} IMAGE: ${{ matrix.image }} run: | export title="Vulnerable image running in production: $IMAGE" # search narrows the list; the jq filter keeps exact title matches only open=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ --search "\"$title\" in:title" --json title \ --jq 'map(select(.title == env.title)) | length') if [ "$open" -eq 0 ]; then gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" \ --body "The daily Trivy scan found fixable HIGH/CRITICAL CVEs. Details: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" fi 

Why it works:

  • Trivy downloads a fresh vulnerability database on every run. A CVE published yesterday is matched today against an image you built three months ago. That's the continuous-scanning property, on a daily schedule.
  • A red run is the alert, and the issue is the to-do. Each image is its own matrix job (fail-fast: false), so the run shows which running image has a fixable high or critical CVE. The last step opens one GitHub issue per vulnerable image and skips it if one is already open, so the daily run doesn't pile up duplicates.
  • Scanning only running images keeps it cheap and relevant. You don't spend time on the 400 old tags sitting in ECR.
  • The IAM role only needs ecs:ListClusters, ecs:ListTasks, ecs:DescribeTasks and ECR pull permissions.
  • The grep '\.dkr\.ecr\.' filter keeps only images from ECR. Sidecars pulled from Docker Hub, Quay or public.ecr.aws are left out. Drop the filter if you want those scanned too. They're public, so Trivy can pull them without credentials.

Two things to watch:

  • "Nothing found" should never look like success. If the role loses permissions or a cluster is renamed, the list is empty, the scan job is skipped, and the workflow shows green while scanning nothing. If you always expect running images, add a step that fails when the list is empty.
  • Many accounts? Run the list step once per account by assuming a read-only role in each one (for example, accounts listed from an AWS Organizations OU), then deduplicate images before scanning.

Where should the results go?

Three options, from simplest to richest:

Option Cost Good for
Job log + red run Free the CI gate: the developer sees the table right where the build failed
GitHub issues (as above) Free tracking: assign, discuss, close when fixed
GitHub Security tab (format: sarif + github/codeql-action/upload-sarif) Free for public repos; private repos need GitHub Code Security, a paid add-on priced per active committer a dashboard with per-finding history and dismiss reasons

The Security tab is the nicest view, but for private repositories it's a paid product. If you don't already pay for it, issues give you most of the value for free.

Bonus: the same tool scans your Terraform

Inspector looks at what's already deployed. Trivy can also check your infrastructure code before it's applied:

trivy config ./terraform 

It flags things like SSH open to 0.0.0.0/0 in a security group, unencrypted S3 buckets and RDS instances, or a database without deletion protection. Like the image scan, it can fail CI with --exit-code 1. I'll cover IaC scanning in detail in the next post.

Which one should you pick?

Choose Inspector if:

  • you need EC2 and Lambda coverage, not just containers,
  • your security team works in Security Hub,
  • you want zero maintenance and context-aware prioritisation,
  • and the per-scan and per-instance pricing fits your scale.

Choose Trivy if:

  • your workloads are mostly containers,
  • you want to block vulnerable images before they're pushed,
  • you want findings where developers already work (GitHub, GitLab, your CI),
  • you also want IaC, secret and license scanning from the same tool,
  • or you run in more than one cloud.

Or combine them. A common, cheap setup is: Trivy in CI as the gate and the developer-facing report, ECR basic scan-on-push as a free second opinion, and Inspector only where you really need it (for example, EC2 fleets that hold sensitive data).

Wrap-up

  • Trivy is a free, fast scanner for images, code and IaC that runs anywhere. Inspector is AWS's managed, continuous scanner for ECR, EC2 and Lambda.
  • The real difference is when they scan. CI scanning alone misses CVEs published after the build.
  • If you replace Inspector with Trivy, scan what's running on a schedule, and make sure an empty scan fails loudly instead of passing silently.