Integrations

How to Scan Docker Images in GitLab CI

Published July 17, 2026 · 9 min read

GitLab CI container scanning turns a written security policy into a pipeline stage that actually blocks bad merges. This guide builds a complete .gitlab-ci.yml pipeline that builds your image with Docker-in-Docker, scans it, and fails the merge request when critical vulnerabilities show up — and explains every line of it.

Scanning Docker images in GitLab CI

Why scan in GitLab CI specifically

GitLab CI has two properties that make it a good place to enforce scanning: merge request pipelines run before code reaches the default branch, and pipeline status is a first-class signal that branch protection rules can require. Put a scan job in the same pipeline that builds the image, and every image that reaches your registry has been checked — not as a separate manual step someone can skip under deadline pressure, but as a pipeline stage that has to pass.

The approach below scans the image as a build artifact: build it with Docker-in-Docker inside the job, export it with docker save, and scan the resulting tar file. That mirrors the pattern in our general image-scanning guide and in the GitHub Actions version of this workflow, adapted to GitLab's dind service and job model.

The complete pipeline

Save this as .gitlab-ci.yml at the root of your repository. It builds the image using the Docker-in-Docker service, scans it with ScanRook, keeps the JSON report as a pipeline artifact, and fails the job on critical or high findings:

stages:
  - build
  - scan

variables:
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TAR: "myapp.tar"

build_image:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  script:
    - docker build -t myapp:ci .
    - docker save myapp:ci -o "$IMAGE_TAR"
  artifacts:
    paths:
      - "$IMAGE_TAR"
    expire_in: 1 hour

scan_image:
  stage: scan
  image: alpine:3.20
  needs: ["build_image"]
  cache:
    key: "scanrook-cache-$CI_COMMIT_REF_SLUG"
    paths:
      - .scanrook-cache/
  before_script:
    - apk add --no-cache curl jq bash
    - curl -fsSL https://scanrook.sh/install | sh
  script:
    - scanrook scan --file "$IMAGE_TAR" --cache-dir "$CI_PROJECT_DIR/.scanrook-cache" --mode deep --format json --out report.json
    - |
      CRITICAL=$(jq '.summary.critical // 0' report.json)
      HIGH=$(jq '.summary.high // 0' report.json)
      echo "Critical: $CRITICAL, High: $HIGH"
      if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
        jq '.findings[] | select(.severity == "CRITICAL" or .severity == "HIGH") | {cve, package: .package.name, version: .package.version, severity}' report.json
        echo "Failing pipeline: critical or high severity vulnerabilities found"
        exit 1
      fi
  variables:
    NVD_API_KEY: $NVD_API_KEY
  artifacts:
    when: always
    paths:
      - report.json
    expire_in: 30 days
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

What each part does

  • Two stages, one artifact. The build_image job builds and saves the image, passing the tar to scan_image through job artifacts. Splitting the stages means a failed scan does not force a rebuild — you can re-run just the scan job if a transient network error hits an advisory source.
  • Docker-in-Docker. The docker:27-dind service gives the build job a Docker daemon to build against. This requires the GitLab Runner executor to allow privileged mode — confirm with your platform team if you are on shared runners, since some organizations disable it by default for security reasons.
  • A lighter scan image. The scan job does not need Docker at all, only the exported tar, so it runs in a plain alpine image without privileged mode — smaller attack surface and faster job startup.
  • GitLab-native caching. The cache: block persists the scanner's data directory between runs on the same runner, keyed by branch so merge request and default-branch pipelines do not thrash each other's cache.
  • Rules, not only branches.The job runs on merge request pipelines, pushes to the default branch, and scheduled pipelines — the three triggers that, together, catch both newly introduced CVEs and advisories published after merge.
  • Always-on artifacts. when: always keeps report.json downloadable from the job page even when the gate step fails, which is exactly when you want to open it.

Failing merge requests on severity

A gate that fails every merge request on day one gets a bypass rule added within a week; a gate with no bite is theater. Three changes make this workable in practice:

Start critical-only. Drop the $HIGH condition from the if check and let high-severity findings show up in the job log without blocking. Inherited base-image findings can number in the hundreds — see how to reduce CVEs in Docker images for bringing that number down before tightening the gate to include highs.

Make it a required pipeline.In GitLab, add the scan job to your branch's merge checks (Settings → Merge requests → Merge checks → Pipelines must succeed). Without that setting, a failing job is visible but does not block the merge button.

Gate on new findings on merge requests, keep audit scope on schedule. Store the report artifact from the last default-branch pipeline and diff merge request scans against it, failing only on newly introduced CVEs. Run the full, ungated scan on the scheduled pipeline so nothing silently ages out of view.

Rollout checklist: from first scan to enforced gate

The pipeline above is the easy half. Turning it into a gate people trust is a sequence, not a switch — work through these in order and the enforcement day is uneventful:

Phase 1 — Observe (before the gate has teeth)

  • Add the scan job with allow_failure: true so a finding never blocks anyone on day one.
  • Open a completed job and download report.json from the job page — if you cannot retrieve the artifact, the gate has no evidence trail.
  • Record a baseline of what the scan reports for each image you build, so you can tell an inherited base-image finding from something your change introduced.
  • Compare job duration on a cold cache versus a warm one, and decide whether the cache: block is pulling its weight on your runners.

Phase 2 — Prepare (decisions, not YAML)

  • Confirm your runners actually allow privileged mode; if they do not, switch to the registry-pull variant that skips the dind service entirely.
  • Store NVD_API_KEY as a masked and protected CI/CD variable, and check that protected-branch pipelines can still read it.
  • Agree the severity threshold with the team that will be blocked by it, in writing, before it is enforced.
  • Define the exception path: who approves a temporary waiver, where it is recorded, and when it expires.
  • Decide who watches scheduled-pipeline failures — a nightly job has no merge request author to notice it went red.

Phase 3 — Enforce

  • Remove allow_failure: true from the scan job.
  • Turn on Settings → Merge requests → Merge checks → Pipelines must succeed, or a red job stays advisory.
  • Set the report artifact expiry to match the scan history your audit or incident review actually needs.
  • Verify the scheduled pipeline is enabled and pointed at the default branch, not just the merge request trigger.
  • Run one deliberate failure — build an image you know is vulnerable — and confirm the merge button is genuinely blocked.

Phase 4 — Maintain

  • Re-baseline after every base image bump; a big swing in findings is expected there and should not be read as a regression.
  • Review open waivers on a fixed cadence and close the ones whose fix has since shipped.
  • Move the job into a shared include: project template once a second repository needs it, so the gate is defined in one place.
  • Re-check the pinned docker and dind image tags periodically — a pin that never moves is its own stale-dependency problem.
Phased rollout checklist for the GitLab CI scan job described above. Operational guidance, not scan data — no finding counts or timings are implied.

Scanning an image that already exists in the registry

Not every scan needs a fresh build. To check a base image you are evaluating or the tag currently deployed to production, pull and save it without the dind service at all:

scan_deployed_image:
  stage: scan
  image: docker:27
  script:
    - docker pull "$CI_REGISTRY_IMAGE:prod"
    - docker save "$CI_REGISTRY_IMAGE:prod" -o deployed.tar
    - curl -fsSL https://scanrook.sh/install | sh
    - scanrook scan --file deployed.tar --format json --out deployed.json
  artifacts:
    paths:
      - deployed.json
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

Running this alongside the build-time scan on the nightly schedule gives you two reports: what the next merge would ship versus what is actually running. A widening gap between them is a signal to redeploy even if nothing in the repository has changed.

Operational notes

  • Store NVD_API_KEY as a masked, protected CI/CD variable in project settings, never inline in the YAML.
  • Pin the docker:27 and docker:27-dind image tags to a specific patch version for reproducible builds across runners.
  • For a multi-project group building several images, define the scan job once in an included template (include: project) so every repository inherits the same gate without copy-pasting YAML.
  • Keep report artifacts from default-branch pipelines beyond the default expiry if your compliance program needs a scan history — GitLab's artifact retention can be extended per job.

Full configuration reference, including a variant that diffs SBOMs between pipeline runs, is in the ScanRook docs.

Frequently asked questions

How do I scan a Docker image in GitLab CI?

Build with Docker-in-Docker, export with docker save, install the scanner CLI, and scan the tar. A jq step reads the report and fails the job past your severity threshold.

Should scans run on merge requests or only on main?

Both. Merge request pipelines catch what you are introducing; a scheduled pipeline on the default branch catches advisories published after merge.

How do I fail the pipeline on critical CVEs?

Read severity counts from the JSON report with jq and exit non-zero past your threshold, then require the job to pass in your merge checks.

Do I need privileged mode for this?

Only for the build job using Docker-in-Docker. Scanning an image already in a registry needs no privileged access at all.

Gate your GitLab pipeline with ScanRook

Drop the pipeline above into your project and every image you build is checked against OSV, NVD, and vendor advisory data before it merges — with JSON reports you can gate on, archive, and diff between pipeline runs.

Related Posts

More on this topic.