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·Updated ·5 min readIntermediate·By SecOpsLog · command-tested

Whether an image contains known vulnerabilities is not in doubt; every base image does. The question a pipeline decides is where you learn about them: in the merge request that introduced the base image, or in the incident channel after it has been the default deploy target for a month. The difference is entirely in job ordering. A scan that runs after docker push is a report; a scan that the push job depends on is a control.

Build, report, gate, push

Two scan passes read the same image. The report pass exits 0 and lists everything; the gate pass exits 1 on CRITICAL only. Push depends on the gate through needs:, so a failed gate leaves the registry untouched.

1buildimage tagged with the commit…2scan: reportall severities, exit 03scan: gateCRITICAL, fixable, exit 14pushneeds: [scan-gate]; nothing…5sbomCycloneDX per image, kept as…
bash — observed: the two passes on a 2023 base image (alpine:3.18.0 saved as image.tar, Trivy 0.74.0)observed
trivy image --input image.tar --severity LOW,MEDIUM,HIGH,CRITICAL --exit-code 0 -q | grep ^Total; echo "exit ${PIPESTATUS[0]}"
Total: 49 (LOW: 14, MEDIUM: 26, HIGH: 6, CRITICAL: 3)
exit 0
trivy image --input image.tar --severity CRITICAL --ignore-unfixed --exit-code 1; echo "exit $?"
INFO Detected OS family="alpine" version="3.18.0"
INFO [alpine] Detecting vulnerabilities... os_version="3.18" repository="3.18" pkg_num=15
WARN This OS version is no longer supported by the distribution
image.tar (alpine 3.18.0)
Total: 3 (CRITICAL: 3)
│ busybox │ CVE-2022-48174 │ CRITICAL │ fixed │ 1.36.0-r9 │ 1.36.1-r1 │ busybox: stack overflow vulnerability in ash.c leads to │
│ busybox-binsh │ │ │ │ │ │ arbitrary code execution │
│ ssl_client │ │ │ │ │ │ │
exit 1
same image, same database: the report pass lists 49 and exits 0, the gate pass lists the three fixable CRITICALs (one CVE in three busybox packages) and exits 1, so the push job never runs for this commit. At HIGH the gate would also be red (six fixable)

The pipeline

Build saves the image as a tar artifact so the scan jobs read exactly the bytes that will be pushed, without pulling from a registry they are supposed to be protecting. The build job is shown with docker; on runners without a Docker socket, buildah build followed by buildah push … oci:image/ produces an OCI layout that trivy image --input reads the same way. The scanner image is pinned to a version; a floating tag on the tool that enforces policy is a supply-chain hole of its own. The vulnerability database is pulled as an OCI artifact from mirror.gcr.io/aquasec first and ghcr.io/aquasecurity second; caching TRIVY_CACHE_DIR under a project-wide key means one download per DB update instead of one per job, which also keeps a busy runner fleet away from registry rate limits.

.gitlab-ci.yml
variables:
IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
TRIVY_CACHE_DIR: .trivycache/
stages: [build, scan, push]
build:
stage: build
script:
- docker build -t "$IMAGE" .
- docker save "$IMAGE" -o image.tar
artifacts:
paths: [image.tar]
expire_in: 1 day
.trivy:
stage: scan
image:
name: aquasec/trivy:0.74.0
entrypoint: [""]
cache:
key: trivy-db
paths: [.trivycache/]
needs: [build]
scan-report:
extends: .trivy
script:
- trivy image --input image.tar --severity LOW,MEDIUM,HIGH,CRITICAL --exit-code 0
- trivy image --input image.tar --format cyclonedx --output sbom.cdx.json
artifacts:
paths: [sbom.cdx.json]
scan-gate:
extends: .trivy
script:
- trivy image --input image.tar --severity CRITICAL --ignore-unfixed --exit-code 1
push:
stage: push
needs: [build, scan-gate]
script:
- docker load -i image.tar
- docker push "$IMAGE"

Why CRITICAL only, and why two passes

A gate that blocks on every HIGH in a freshly adopted base image is disabled within a month, usually with allow_failure: true and a comment promising to revisit. The report pass keeps the full list visible in every pipeline so the backlog is known; the gate pass blocks only on what a developer can fix now, so --ignore-unfixed is on it. Tightening is a per-project decision to make when the backlog is short: move HIGH into the gate pass for a service once its report has been clean at that level for a few weeks, not for the whole organisation on a date.

Two things make the gate a control rather than a report. scan-gate carries no allow_failure, so a red gate fails the pipeline and the push stage never starts. And push names the gate in needs, which makes the dependency explicit rather than a side effect of stage order: a job with needs runs only when every job it lists has succeeded, however the stages are rearranged later. When the gate is red, the image tagged with that commit exists only as an artifact that expires in a day.

bash — observed: an exception with an expiry date, then the same file after the dateobserved
cat .trivyignore.yaml
vulnerabilities:
- id: CVE-2022-48174
statement: accepted until the base image bump, INFRA-442
expired_at: 2027-01-01
trivy image --input image.tar --severity CRITICAL --ignore-unfixed --exit-code 1 --ignorefile .trivyignore.yaml; echo "exit $?"
INFO Some vulnerabilities have been ignored/suppressed. Use the "--show-suppressed" flag to display them.
│ image.tar (alpine 3.18.0) │ alpine │ 0 │ - │
exit 0
sed -i "s/2027-01-01/2024-01-01/" .trivyignore.yaml && trivy image --input image.tar --severity CRITICAL --ignore-unfixed --exit-code 1 --ignorefile .trivyignore.yaml -q | grep ^Total; echo "exit ${PIPESTATUS[0]}"
Total: 3 (CRITICAL: 3)
exit 1
the exception is a file in the repository, reviewed like code, and it lapses on its own; a bad YAML value (a colon inside an unquoted statement) made Trivy exit 1 with an error, which the gate treats the same as a finding

When the gate is wrong: recovery in order of preference

SituationDoDo not
a CRITICAL with a fix that the base image does not carry yet.trivyignore.yaml entry with expired_at a few weeks out and the ticket in statement, in the same merge requestallow_failure: true on scan-gate: needs still passes and the push runs on every red gate from then on
the gate is red on a hotfix that must shippush from a pipeline that runs the report pass and records the exception first; the hotfix carries the .trivyignore.yaml change, and the next scheduled registry scan re-opens itretag the previous image by hand: the registry now holds an image no pipeline scanned
the database download fails and every job is redset TRIVY_SKIP_DB_UPDATE=true on the gate job with the cached .trivycache/ (the last good database) until the mirror recovers; keep the report pass running so the outage is visible--exit-code 0 on the gate to get green: the control is gone and nothing says so
What was run for this article
Trivy 0.74.0 (the aquasec/trivy:0.74.0 image, Docker Engine 28.5.2, linux/arm64) against alpine:3.18.0 pulled by digest, saved with docker save and scanned with --input, the way the jobs read the build artifact; database as downloaded on 2026-09-13. The terminal blocks marked observed are copied from that run (the CycloneDX pass wrote a 1.7 document with 16 components); six exit codes are asserted by the fixture script. Finding counts move with the database, exit codes and output shape do not. The GitLab pipeline, the runner cache and the Buildah path are representative and were not executed. In the recovery table, the expiring exception is what the run showed; the hotfix flow and TRIVY_SKIP_DB_UPDATE follow the documented model.
Registry scanning arrives after the push
Scanning images already in the registry tells you what is deployed and vulnerable; it cannot stop the push. Keep both: the CI gate for what is about to ship, and a registry or runtime scan for CVEs disclosed after the build, which is most of them over an image’s lifetime.
Report pass vs gate pass
scan-report (exit 0)
Every severity, unfixed included
Produces the SBOM
Shows the backlog on each MR
Never blocks
scan-gate (exit 1)
CRITICAL, fixable only
push depends on it
Tightened per project, deliberately
Exceptions via .trivyignore.yaml with expiry

The same two flags drive the dependency scan on the source tree one stage earlier, where the suppression file with reasons and expiry dates is described. After the gate, the natural next control is to sign the image that passed, so that admission in the cluster can distinguish an image this pipeline vouched for from one that merely has the same name.

Go deeper in a courseSecure CI/CD with GitLabScanning, signing, SBOMs and policy gates as one pipeline.View course

Related posts

Quick reference