BlogCI/CD

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.

May 26, 2026·9 min readIntermediate

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:

bash — trivy scanlive
trivy image --severity CRITICAL,HIGH myapp:1.4.2
2025-11-25T10:02:14Z INFO Vulnerability scanning is enabled
2025-11-25T10:02:14Z INFO Detected OS: alpine 3.19.1
2025-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-r6
CRITICAL libcrypto3 CVE-2024-5535 fixed in 3.1.4-r5
HIGH busybox CVE-2023-42366 fixed in 1.36.1-r15
HIGH curl CVE-2024-2398 fixed in 8.5.0-r1
HIGH libcurl CVE-2024-7264 fixed in 8.9.1-r0
 
exit status 1 — build failed: 2 CRITICAL over threshold

Why 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.

Where the gate sits
1
Build
docker build
2
Scan (report)
LOW→HIGH, exit 0
3
Scan (gate)
CRITICAL, exit 1
4
Push
only if gate passes

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.

.gitlab-ci.yml
container_scan:
stage: test
image:
name: aquasec/trivy:0.53.0
entrypoint: [""]
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.

.gitlab-ci.yml
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.

Rate limits will find you
Trivy pulls its vulnerability DB from ghcr.io on every fresh job — about 40 MB. On a busy runner fleet that gets throttled fast. Cache it (next section).

Cache the vulnerability DB

Point Trivy's cache at a directory GitLab persists between jobs, keyed once for the whole project:

.gitlab-ci.yml
variables:
TRIVY_CACHE_DIR: .trivycache/
cache:
key: trivy-db
paths:
- .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

Related posts