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 is not 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 does not become the slowest job in your pipeline.
The architecture is deliberately split: build produces an image, a report pass surfaces everything, a gate pass blocks on CRITICAL only, and push runs last. That ordering matters — scanning after build but before registry push means nothing vulnerable ever becomes the default deploy target. For the full hardening sequence, see Secure CI/CD with GitLab.
Push must depend on the gate job. A scan that runs after push is a report, not a control.
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-r15 exit status 1 — build failed: 2 CRITICAL over thresholdWhy scan in CI, not after
Registry scanning tells you what is 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 are here to prevent. Use the official aquasec/trivy image with an empty entrypoint so GitLab can run shell scripts normally.
variables:TRIVY_CACHE_DIR: .trivycache/IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"container_scan:stage: testimage:name: aquasec/trivy:0.53.0entrypoint: [""]cache:key: trivy-dbpaths: [.trivycache/]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. 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. Wire container_push with needs: [container_scan] so nothing reaches the registry when the gate fails.
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"
Cache the vulnerability DB
Point Trivy's cache at a directory GitLab persists between jobs, keyed once for the whole project. With a warm cache the scan drops from ~90s to under 30s on a typical service image. That is cheap enough to run on every commit, which is the point — a scan you skip on feature branches is a scan that misses the merge request where the bad base image landed.
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 Secure CI/CD with GitLab course covers the full sequence — scanning, signing, SBOMs and policy gates end to end.
Go deeper in a courseSecure CI/CD with GitLabScanning, signing, SBOMs and policy gates — end to end.View course