Container Scanning on GitLab MRs with Trivy and Grype
How to add parallel Trivy and Grype container scanning to GitLab merge requests using a shared CI template, so every Renovate-raised image update gets scanned for CVEs and supply chain issues before it merges.
Contents
In the previous post we got Renovate running on self-hosted GitLab CE, automatically discovering repos and raising MRs when new image versions are available. That handles the update workflow — but it doesn’t tell you whether the new image is actually safe to run. This post adds container scanning to the picture: every MR that touches a Docker Compose file gets scanned by both Trivy and Grype before it can merge.
Why Two Scanners? #
Trivy and Grype cover overlapping but not identical ground. Trivy is strong on OS-level CVEs and is fast to run. Grype has good coverage of supply chain compromises and malicious packages — things that might not show up as a CVE but indicate something wrong with the image’s provenance. Running both in parallel costs almost nothing extra and gives you better signal than either alone.
Neither of these is a malware scanner in the antivirus sense. If you need filesystem-level malware detection, ClamAV or YARA rules are worth adding on top. But for a GitOps workflow where Renovate is proposing image updates, CVE and supply chain coverage is where most of the risk sits.
The Approach #
Rather than adding scanning logic to every repo individually, we use a shared CI template in a ci-templates repo. Any compose repo can adopt it with two lines. The template runs three jobs on every MR:
- resolve-images — diffs the MR against the target branch, finds changed compose files, extracts unique image references, and writes them to
images.txt - trivy-scan — reads
images.txtand scans each image with Trivy - grype-scan — reads the same
images.txtand scans each image with Grype
Trivy and Grype run in parallel via GitLab’s needs: keyword, both depending on resolve-images. The image list is resolved once and shared as a job artifact — there’s no duplicated compose-parsing logic.
Scoping to changed files only matters here. A repo with 20 compose files across different directories would be slow and noisy if every MR scanned everything. Because Renovate only touches the specific compose file containing the updated image, the diff approach means you’re scanning exactly what changed — usually one or two images per MR.
The Shared Template #
Create a ci-templates repo in your GitLab group and add a container-scan.yml. Here’s the full template:
stages:
- container-scan
.resolve-images:
stage: container-scan
image: alpine:latest
variables:
SCAN_SEVERITY: "HIGH,CRITICAL"
SCAN_IGNORE_UNFIXED: "true"
SCAN_FAIL_ON_VULN: "true"
before_script:
- apk add --no-cache yq git
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- |
git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --depth=20
CHANGED_COMPOSE=$(git diff --name-only \
"origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}...HEAD" \
| grep -E '(docker-compose|compose)\.(yml|yaml)$' || true)
if [ -z "$CHANGED_COMPOSE" ]; then
echo "No compose files changed — skipping scan."
touch images.txt
exit 0
fi
echo "$CHANGED_COMPOSE" \
| xargs -I{} yq e '.services[].image // ""' {} \
| grep -v '^$' \
| grep -v '^null$' \
| sort -u > images.txt
cat images.txt
artifacts:
paths:
- images.txt
expire_in: 1 hour
.trivy-scan:
stage: container-scan
image:
name: ghcr.io/aquasecurity/trivy:latest
entrypoint: [""]
variables:
SCAN_SEVERITY: "HIGH,CRITICAL"
SCAN_IGNORE_UNFIXED: "true"
SCAN_FAIL_ON_VULN: "true"
TRIVY_INSECURE: "false"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- |
if [ ! -s images.txt ]; then
echo "No images to scan — skipping Trivy."
exit 0
fi
TRIVY_FLAGS="--no-progress --format template --template @/contrib/junit.tpl"
TRIVY_FLAGS="$TRIVY_FLAGS --severity $SCAN_SEVERITY"
[ "$SCAN_IGNORE_UNFIXED" = "true" ] && TRIVY_FLAGS="$TRIVY_FLAGS --ignore-unfixed"
[ "$SCAN_FAIL_ON_VULN" = "true" ] && TRIVY_FLAGS="$TRIVY_FLAGS --exit-code 1"
mkdir -p reports/trivy
OVERALL_EXIT=0
while IFS= read -r IMAGE; do
[ -z "$IMAGE" ] && continue
SAFE_NAME=$(echo "$IMAGE" | tr '/: ' '---')
# shellcheck disable=SC2086
trivy image $TRIVY_FLAGS \
--output "reports/trivy/${SAFE_NAME}.xml" \
"$IMAGE" || OVERALL_EXIT=1
done < images.txt
exit $OVERALL_EXIT
artifacts:
when: always
reports:
junit: reports/trivy/*.xml
paths:
- reports/trivy/
expire_in: 7 days
.grype-scan:
stage: container-scan
image: alpine:latest
variables:
SCAN_SEVERITY: "HIGH,CRITICAL"
SCAN_IGNORE_UNFIXED: "true"
SCAN_FAIL_ON_VULN: "true"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
before_script:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/templates/junit.tmpl -o /usr/local/share/grype-junit.tmpl
script:
- |
if [ ! -s images.txt ]; then
echo "No images to scan — skipping Grype."
exit 0
fi
GRYPE_FAIL_ON="high"
echo "$SCAN_SEVERITY" | grep -qi "MEDIUM" && GRYPE_FAIL_ON="medium"
echo "$SCAN_SEVERITY" | grep -qi "LOW" && GRYPE_FAIL_ON="low"
GRYPE_FLAGS="--output template -t /usr/local/share/grype-junit.tmpl"
[ "$SCAN_IGNORE_UNFIXED" = "true" ] && GRYPE_FLAGS="$GRYPE_FLAGS --only-fixed"
[ "$SCAN_FAIL_ON_VULN" = "true" ] && GRYPE_FLAGS="$GRYPE_FLAGS --fail-on $GRYPE_FAIL_ON"
mkdir -p reports/grype
OVERALL_EXIT=0
while IFS= read -r IMAGE; do
[ -z "$IMAGE" ] && continue
SAFE_NAME=$(echo "$IMAGE" | tr '/: ' '---')
# shellcheck disable=SC2086
grype "$IMAGE" $GRYPE_FLAGS \
--file "reports/grype/${SAFE_NAME}.xml" || OVERALL_EXIT=1
done < images.txt
exit $OVERALL_EXIT
artifacts:
when: always
reports:
junit: reports/grype/*.xml
paths:
- reports/grype/
expire_in: 7 days
resolve-images:
extends: .resolve-images
trivy-scan:
extends: .trivy-scan
needs:
- job: resolve-images
artifacts: true
grype-scan:
extends: .grype-scan
needs:
- job: resolve-images
artifacts: true
A Few Things Worth Calling Out #
Trivy comes from GHCR, not Docker Hub. The aquasec/trivy:latest tag on Docker Hub doesn’t exist — Trivy’s canonical public image is ghcr.io/aquasecurity/trivy:latest. This will cause a manifest not found error if you use the Docker Hub reference.
Grype’s official image is distroless. GitLab’s runner needs a shell to exec into the container to run the job script. The Grype image has no shell at all, so the job fails immediately with exec: "sh": executable file not found in $PATH. The fix is to use Alpine and install Grype via the official install script at runtime. It adds a few seconds per job but is straightforward and reliable.
Grype dropped native JUnit output. The --output junit flag no longer exists in recent Grype versions. Instead, Grype supports a template output format where you supply a Go template file. The Anchore team maintain an official JUnit template in the Grype repo, so the before_script downloads it alongside the Grype binary. The template approach is actually more flexible — you could swap in a custom template if you needed different output.
Artifacts must be in the project directory. GitLab artifacts have to be within $CI_PROJECT_DIR. Writing images.txt to /tmp seems natural but the artifact upload silently fails with a warning and the downstream jobs get an empty file. Write it to the working directory and reference it as a relative path in the artifacts: block.
Adopting the Template #
Any repo with compose files can adopt the template by adding a .gitlab-ci.yml:
include:
- project: 'lab/ci-templates'
ref: main
file: '/container-scan.yml'
That’s it for the default configuration. The jobs run automatically on every MR that touches a compose file. To override behaviour for a specific repo, extend the concrete jobs:
include:
- project: 'lab/ci-templates'
ref: main
file: '/container-scan.yml'
trivy-scan:
variables:
SCAN_SEVERITY: "MEDIUM,HIGH,CRITICAL"
SCAN_FAIL_ON_VULN: "true"
TRIVY_USERNAME: $MY_REGISTRY_USER
TRIVY_PASSWORD: $MY_REGISTRY_PASSWORD
grype-scan:
variables:
SCAN_FAIL_ON_VULN: "false" # advisory only while baselining
A useful pattern when rolling out across a group for the first time is to set SCAN_FAIL_ON_VULN: "false" on the Grype job initially. Grype’s supply chain checks can be noisy on older images and it’s worth baselining what it finds before making it a hard gate. Trivy can be blocking from day one — its CVE output is more predictable.
Blocking Merges on Findings #
For the scan to actually block a merge, two things need to be true. First, SCAN_FAIL_ON_VULN must be true so the job exits non-zero on findings. Second, the repo needs Settings → Merge requests → Pipelines must succeed enabled. Without that setting, a failing pipeline is visible in the MR but doesn’t prevent merging.
This also interacts with Renovate’s automerge setting. If you’ve enabled automerge for patch updates, Renovate will only automerge if all required status checks pass — so a failing scan will hold it back correctly without any extra config.
Ignoring Known CVEs #
Both scanners will find CVEs in base images you don’t control and can’t fix. To keep results actionable, SCAN_IGNORE_UNFIXED: "true" is on by default — this filters out CVEs where no fix is available upstream, which cuts the noise significantly.
For CVEs you’ve assessed and accepted, add a .trivyignore file at the repo root:
# .trivyignore
# CVE-ID expiry comment
CVE-2023-1234 2024-12-01 # upstream fix not yet released
CVE-2023-5678 # not exploitable in our context
Trivy picks this up automatically. Grype has an equivalent via a .grype.yaml ignore list if you need per-CVE suppression there too.
The Full Picture with Renovate and Portainer #
With both posts in place, the end-to-end flow for a GitOps Portainer setup looks like this:
- Renovate detects a new image tag and waits 7 days
- Renovate opens an MR updating the tag in the relevant compose file
- The CI pipeline triggers:
resolve-imagesdiffs the MR and extracts the changed image, thentrivy-scanandgrype-scanrun in parallel - Results appear as a JUnit test report in the MR UI — pass or fail per image, per CVE
- If both scanners pass, the MR can be merged (or automerged if configured)
- Portainer detects the change on the main branch and redeploys the stack with the new image
The 7-day hold and the scanning gate together mean you’re not deploying a brand new image immediately and you’re not deploying one with known exploitable CVEs. It’s not a complete supply chain story — digest pinning and SBOM generation are worth adding if you want to go further — but it’s a solid baseline for a self-hosted GitOps setup without any paid tooling.