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·4 min readIntermediate·By the SecOpsLog team · command-tested

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.

Pipeline architecture: build → scan → gate → push

Push must depend on the gate job. A scan that runs after push is a report, not a control.

1docker buildtag with commit SHA2Scan (report)LOW→HIGH, exit 03Scan (gate)CRITICAL, exit 14Cache vuln DB.trivycache/ keyed5needs: [gate]push waits on scan6Push to registryonly if gate green7Optional SBOMcyclonedx artifact
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
 
exit status 1 — build failed: 2 CRITICAL over threshold

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

.gitlab-ci.yml
variables:
TRIVY_CACHE_DIR: .trivycache/
IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
container_scan:
stage: test
image:
name: aquasec/trivy:0.53.0
entrypoint: [""]
cache:
key: trivy-db
paths: [.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.

.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"
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 .trivycache/ with a project-level key and refresh on a schedule if your org requires it. Cold scans without cache routinely hit 90s; warm cache should stay under 30s.

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

Related posts