Overview and What You Will Learn
Every Docker image you pull from the internet contains an operating system, runtime, and libraries — all of which accumulate known vulnerabilities (CVEs) over time. Without scanning, you are running containers with known security holes in production. At Razorpay, where containers process financial transactions, a compromised container from an unscanned image with a critical vulnerability is a regulatory and business disaster.
In this guide you will learn how to scan images with Trivy (the industry standard, free, open-source), integrate scanning into GitHub Actions so no unscanned image reaches production, and understand how to interpret and act on scan results.
Core Principles
+------------------------------------------+| Where vulnerabilities come from: || || Base image OS packages || ubuntu:22.04 has 200+ CVEs || alpine:3.18 has ~0 CVEs || || Language runtime packages || old node version = known CVEs || old python packages = known CVEs || || Your application dependencies || npm packages, pip packages || old lodash, old requests library |+------------------------------------------+ | v+------------------------------------------+| Trivy scans all three sources: || OS packages, language deps, app files || Returns: CVE ID, severity, fixed version |+------------------------------------------+ | v+------------------------------------------+| CI pipeline gate: || CRITICAL found -> fail build || HIGH found -> warn or fail || MEDIUM/LOW -> report only |+------------------------------------------+Detailed Step-by-Step Practical Lab
Milestone 1: Installing and Running Trivy
# Install Trivy# macOSbrew install aquasecurity/trivy/trivy # Linux (Debian/Ubuntu)sudo apt-get install wget apt-transport-https gnupg lsb-releasewget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | \ sudo apt-key add -echo "deb https://aquasecurity.github.io/trivy-repo/deb \ $(lsb_release -sc) main" | \ sudo tee -a /etc/apt/sources.list.d/trivy.listsudo apt-get update && sudo apt-get install trivy # Linux (using the install script)curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh # Verify installationtrivy --version# Version: 0.48.0 # Scan a local imagetrivy image payment-api:latest # Sample output:# payment-api:latest (alpine 3.18.4)# Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)## Node.js (node-pkg)# Total: 3 (LOW: 0, MEDIUM: 2, HIGH: 1, CRITICAL: 0)## HIGH: lodash 4.17.20# CVE-2021-23337 -- Command Injection Vulnerability# Fixed Version: 4.17.21Milestone 2: Understanding Scan Output
# Scan with table output (default) — human readabletrivy image nginx:1.25 # Scan with JSON output — for CI pipelines and automationtrivy image --format json nginx:1.25 > scan-results.json # Scan with only CRITICAL and HIGH severity (ignore LOW/MEDIUM noise)trivy image --severity CRITICAL,HIGH payment-api:latest # Exit code reflects findings:# 0 = no vulnerabilities found# 1 = vulnerabilities found# Use in scripts: trivy image myimage && echo "PASS" || echo "FAIL" # Scan a remote image (pulls from registry)trivy image registry.razorpay.in/payment-api:v3.1.0 # Scan with a timeout for large imagestrivy image --timeout 10m large-image:latest # Show only fixable vulnerabilities (where a patched version exists)trivy image --ignore-unfixed payment-api:latest# Only shows CVEs that have a fix available — more actionableMilestone 3: Scanning Filesystem and Configs
Trivy scans more than just images:
# Scan the local filesystem for vulnerabilities in your codetrivy fs .# Scans: package.json, requirements.txt, go.sum, Cargo.lock# Finds vulnerabilities in YOUR dependencies before building the image # Scan a Dockerfile for misconfigurationstrivy config Dockerfile# Checks for: running as root, no HEALTHCHECK, using ADD instead of COPY,# using :latest tags, missing USER instruction # Scan docker-compose.ymltrivy config docker-compose.yml# Checks for: missing resource limits, privileged containers,# exposed sensitive ports, no health checks # Scan infrastructure-as-code filestrivy config terraform/trivy config k8s/Milestone 4: GitHub Actions Integration
This is the most important milestone — blocking vulnerable images before they reach production:
# .github/workflows/build-scan-push.ymlname: Build, Scan, and Push on: push: branches: [main] pull_request: branches: [main] env: REGISTRY: registry.razorpay.in IMAGE_NAME: payment-api jobs: build-and-scan: runs-on: ubuntu-latest permissions: contents: read security-events: write # Required for GitHub Security tab upload steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build image (do not push yet) uses: docker/build-push-action@v5 with: context: . push: false tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} load: true # Load into local Docker daemon for scanning cache-from: type=gha cache-to: type=gha,mode=max - name: Run Trivy vulnerability scan uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }} format: sarif # GitHub Security tab format output: trivy-results.sarif severity: CRITICAL,HIGH exit-code: 1 # Fail the pipeline if CRITICAL/HIGH found ignore-unfixed: true # Only fail on fixable vulnerabilities - name: Upload Trivy results to GitHub Security uses: github/codeql-action/upload-sarif@v3 if: always() # Upload even if scan found issues with: sarif_file: trivy-results.sarif - name: Login to registry (only if scan passed) if: github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Push image (only if scan passed and on main) if: github.ref == 'refs/heads/main' uses: docker/build-push-action@v5 with: context: . push: true tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest cache-from: type=gha cache-to: type=gha,mode=maxThe pipeline guarantees: no image with CRITICAL or HIGH CVEs reaches the registry.
Milestone 5: ECR Enhanced Scanning
If you use AWS ECR, you get automatic scanning built in:
# Enable enhanced scanning on an ECR repositoryaws ecr put-image-scanning-configuration \ --repository-name payment-api \ --image-scanning-configuration scanOnPush=true \ --region ap-south-1 # Get scan results for a pushed imageaws ecr describe-image-scan-findings \ --repository-name payment-api \ --image-id imageTag=v3.1.0 \ --region ap-south-1 # ECR Inspector (enhanced scanning) also scans:# - OS packages# - Programming language packages (npm, pip, etc.)# - And continuously re-scans as new CVEs are discovered# This means an image that was clean on push may show CVEs 3 months laterMilestone 6: Keeping Base Images Updated
Scanning is reactive — you find vulnerabilities after they exist. The proactive approach is keeping base images updated:
# Check which base image your Dockerfile useshead -1 Dockerfile# FROM node:20-alpine # Pin to a specific digest for reproducibilitydocker pull node:20-alpinedocker inspect node:20-alpine --format '{{index .RepoDigests 0}}'# node@sha256:a84f9c2b1d3e... # Update Dockerfile to pin by digest# FROM node:20-alpine@sha256:a84f9c2b1d3e...# This guarantees you always build from the exact same base# But also means you have to update the digest when you want updates # Use Renovate or Dependabot to automate base image updates:# These tools open automated PRs when new base image versions are available# Your CI pipeline scans the new image before merging# Combine with: automated base image updates + CI scanning = always currentTrivy Configuration File
For consistent scanning across projects:
# .trivyignore — ignore specific CVEs that are not applicableCVE-2023-12345 # Ignore — only affects PostgreSQL, we don't use itCVE-2023-67890 # Ignore — only affects Windows builds # trivy.yaml — default config (put in project root)severity: - CRITICAL - HIGHexit-code: 1ignore-unfixed: truetimeout: 10mCommon Mistakes
| Mistake | Risk | Fix |
|---|---|---|
| Scanning only on merge to main | Vulnerable PRs pass review | Scan on every PR, not just main |
Not using --ignore-unfixed |
Many false positives from unfixable CVEs | Add --ignore-unfixed to filter to actionable findings |
| Ignoring HIGH severity | HIGH CVEs are frequently exploited | Treat HIGH same as CRITICAL in production services |
| Never updating base images | CVE count grows over time | Use Renovate/Dependabot for automated base image PRs |
| Only scanning the final image | Vulnerable dev dependencies not caught | Also scan filesystem (trivy fs .) to catch dep vulnerabilities early |
Troubleshooting Reference
| Problem | Cause | Fix |
|---|---|---|
| Scan takes too long | Large image with many packages | Add --timeout 15m or scan in parallel with build |
| Too many false positives | Unfixable OS CVEs | Add --ignore-unfixed to filter to only fixable findings |
| Pipeline failing on LOW CVEs | Wrong severity setting | Use --severity CRITICAL,HIGH to ignore LOW/MEDIUM |
| ECR scan shows no results | Scanning not enabled | aws ecr put-image-scanning-configuration --scanOnPush true |
| Cannot scan private registry image | Not authenticated | docker login registry before running trivy |
PLACEMENT PRO TIP**Tip:** Add `trivy fs .` to your development workflow as a pre-commit check. Catching vulnerable npm packages or pip dependencies before building the image means you fix the problem at the source (your package.json) rather than just masking it by pinning a specific version.
REMEMBER THIS**Remember:** A clean scan today does not mean a clean scan tomorrow. New CVEs are discovered daily. Configure ECR enhanced scanning or schedule a weekly `trivy image` run against your production images to catch CVEs that were not known when the image was first built.
COMMON MISTAKE / WARNING**Common Mistake:** Setting `exit-code: 0` in the Trivy GitHub Action to prevent pipeline failures, then treating scanning as a reporting exercise rather than a gate. If your scan never fails the build, it has no protective value. Set `exit-code: 1` and enforce it — the short-term pain of fixing vulnerabilities is worth the long-term protection.
COMMON MISTAKE / WARNING**Security:** At Zerodha and PhonePe, images with CRITICAL CVEs are blocked from production by CI pipeline enforcement. Engineers cannot bypass the scan by pushing directly to the registry — the deployment system only accepts images that were pushed through the CI pipeline, which means every deployed image has passed the scan gate. This is the correct model: make it structurally impossible to deploy an unscanned image.