Scanning container images with Trivy in GitLab CI
Wire Trivy into your pipeline, fail builds on criticals without blocking every merge, and cache the vuln DB so scans stay under 30 seconds.
Every image you ship is a bundle of someone else's CVEs. The question isn't whether your base image has vulnerabilities — it does — but whether you find out in the merge request or in an incident channel. This tutorial adds trivy as a pipeline stage in GitLab CI, tunes it so it fails on what matters, and caches the vulnerability database so the scan doesn't become the slowest job in your pipeline.
Here's the scan you're about to wire up, run against a deliberately vulnerable image — it streams as you scroll in:
trivy image --severity CRITICAL,HIGH myapp:1.4.22025-11-25T10:02:14Z INFO Vulnerability scanning is enabled2025-11-25T10:02:14Z INFO Detected OS: alpine 3.19.12025-11-25T10:02:15Z INFO Scanning 1274 packages ... myapp:1.4.2 (alpine 3.19.1)Total: 5 (HIGH: 3, CRITICAL: 2) CRITICAL openssl CVE-2024-6119 fixed in 3.1.4-r6CRITICAL libcrypto3 CVE-2024-5535 fixed in 3.1.4-r5HIGH busybox CVE-2023-42366 fixed in 1.36.1-r15HIGH curl CVE-2024-2398 fixed in 8.5.0-r1HIGH libcurl CVE-2024-7264 fixed in 8.9.1-r0 exit status 1 — build failed: 2 CRITICAL over thresholdWhy scan in CI, not after
Registry scanning tells you what's already deployed. CI scanning tells you before the image exists anywhere but a branch. The feedback loop is the whole point: a HIGH in a merge request is a ten-minute fix; the same HIGH in production is a change ticket, a rollout, and a postmortem line item.
Trivy is the right default here — a single static binary, no server component, and it scans OS packages and language lockfiles in one pass.
A minimal .gitlab-ci.yml
Add a scan job to the test stage, after your image build. Pin the Trivy version — a floating latest tag on your scanner is exactly the supply-chain problem you're here to prevent.
container_scan:stage: testimage:name: aquasec/trivy:0.53.0entrypoint: [""]variables:IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"script:- trivy image --format table "$IMAGE"
Fail on criticals, not everything
A scanner that blocks every merge gets disabled within a month. Run two passes: report everything, but only let CRITICALs set the exit code.
script:# report everything, fail only on criticals- trivy image --exit-code 0 --severity LOW,MEDIUM,HIGH "$IMAGE"- trivy image --exit-code 1 --severity CRITICAL "$IMAGE"
Teams that need a stricter gate later can move HIGH into the failing pass per-project — policy should tighten as your backlog shrinks, not before.
Cache the vulnerability DB
Point Trivy's cache at a directory GitLab persists between jobs, keyed once for the whole project:
variables:TRIVY_CACHE_DIR: .trivycache/cache:key: trivy-dbpaths:- .trivycache/
With a warm cache the scan drops from ~90s to under 30s on a typical service image. That's cheap enough to run on every commit, which is the point.
Where this goes next
Scanning is table stakes. The next moves are signing what passed (Cosign), verifying signatures at admission (Kyverno), and generating SBOMs so the next log4shell takes you minutes to triage, not days. The course below covers the full sequence.
Go deeper in a courseSecure CI/CD with GitLabScanning, signing, SBOMs and policy gates — end to end.View course