Last updated: March 22, 2026

Shipping a Docker image without scanning it is like deploying code without testing. you are pushing unknown vulnerabilities to production. Most containers are built on base images with hundreds of packages, each with its own CVE history. This guide covers scanning images locally, integrating into CI/CD, and interpreting results.

Tools Overview

Tool Database Best For
Trivy GHSA, NVD, OS advisories Fast,, CI integration
Grype Anchore’s vulnerability DB SBOM integration
Docker Scout Docker’s advisory DB Native Docker tooling

Trivy - The Fast Standard

Install Trivy

Ubuntu/Debian
sudo apt install wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | \
  sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] \
  https://aquasecurity.github.io/trivy-repo/deb generic main" | \
  sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install trivy

macOS
brew install trivy

Scan an Image

Scan a local image
trivy image nginx:1.25

Only show HIGH and CRITICAL
trivy image --severity HIGH,CRITICAL nginx:1.25

Exit with error code 1 if any CRITICAL found (for CI)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

JSON output
trivy image --format json --output scan-results.json myapp:latest

Include secrets and misconfigurations
trivy image --scanners vuln,secret,misconfig myapp:latest

Ignore unfixed vulnerabilities
trivy image --ignore-unfixed myapp:latest

Scan Dockerfile Before Building

trivy config Dockerfile
trivy config .

Grype - SBOM-First Scanning

Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | \
  sudo sh -s -- -b /usr/local/bin

Scan
grype nginx:1.25

Generate SBOM then scan
syft nginx:1.25 -o cyclonedx-json > nginx-sbom.json
grype sbom:nginx-sbom.json

Fail on critical
grype --fail-on critical nginx:1.25

Docker Scout

docker scout cves nginx:1.25
docker scout compare nginx:1.24 nginx:1.25
docker scout recommendations nginx:1.25
docker scout quickview myapp:latest

CI/CD Integration

GitHub Actions with Trivy

.github/workflows/scan.yml
name: Container Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1

      - name: Upload to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

GitLab CI

container-scan:
  image: aquasec/trivy:latest
  stage: test
  script:
    - trivy image --exit-code 1 --severity CRITICAL,HIGH $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

Trivy Ignore Files

When a CVE has no fix or is a false positive:

.trivyignore
CVE-2024-12345
CVE-2024-99999 exp:2026-09-01

Interpreting Results

Prioritize by:

  1. CVSS score 9.0+: patch immediately
  2. Network-reachable component: higher priority
  3. Fix available: upgrade immediately if a patched version exists
  4. EPSS score: probability of exploitation in the wild
Extract CVSS scores for criticals
trivy image --format json myapp:latest | \
  jq '[.Results[].Vulnerabilities[] |
       select(.Severity == "CRITICAL") |
       {vuln: .VulnerabilityID, cvss: .CVSS.nvd.V3Score}]'

SBOM Generation - Software Bill of Materials

An SBOM documents every package in your image. This is required by many enterprises and government contracts.

Generate SBOM with Syft (Anchore's tool)
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | \
  sudo sh -s -- -b /usr/local/bin

Generate CycloneDX format (most common)
syft myapp:latest -o cyclonedx-json > myapp-sbom.json

Generate SPDX format (alternative standard)
syft myapp:latest -o spdx-json > myapp-sbom-spdx.json

View the SBOM
jq '.components | length' myapp-sbom.json
Example output - 847 (847 packages in the image)

An SBOM is essential for:

Private Registry Scanning

If using a private Docker registry, scan images immediately after push:

Configure registry credentials
trivy image --registry-username user --registry-password token \
  my-private-registry.com/myapp:latest

Or use Docker config
trivy image --skip-update my-private-registry.com/myapp:latest

Combine with webhooks to automate:

Setup - Your registry sends webhook to a scanning endpoint
Scanning script - scan-webhook.sh

#!/bin/bash
IMAGE_NAME=$1
IMAGE_TAG=$2

trivy image --exit-code 1 \
  --severity CRITICAL,HIGH \
  "${IMAGE_NAME}:${IMAGE_TAG}"

if [ $? -ne 0 ]; then
    # Send alert
    curl -X POST https://slack.com/api/chat.postMessage \
      -H "Authorization: Bearer $SLACK_TOKEN" \
      -H "Content-Type: application/json" \
      -d "{\"channel\": \"#security\", \"text\": \"Image $IMAGE_NAME:$IMAGE_TAG has critical vulnerabilities\"}"
    exit 1
fi

Tag and push
docker tag "${IMAGE_NAME}:${IMAGE_TAG}" \
  my-registry.com/scanned/"${IMAGE_NAME}:${IMAGE_TAG}"
docker push my-registry.com/scanned/"${IMAGE_NAME}:${IMAGE_TAG}"

Enforcing Minimal Base Images

Use the smallest base images to reduce the attack surface:

Base Image Size Packages CVEs (typical)
ubuntu:latest 77 MB 350+ 20-50
debian:bookworm-slim 65 MB 200+ 15-30
alpine:latest 7 MB 50+ 2-5
scratch (empty) 0 MB 0 0
Bad - Based on full Ubuntu
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl wget git
... 450 MB final image

Better - Slim variant
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y curl
... 150 MB final image

Best - Multi-stage with alpine
FROM golang:1.21-alpine as builder
WORKDIR /src
COPY . .
RUN go build -o myapp

FROM alpine:latest
COPY --from=builder /src/myapp /usr/local/bin/
... 50 MB final image

Optimal - Scratch for compiled languages
FROM scratch
COPY --from=builder /src/myapp /myapp
... 15 MB final image (just the binary)

For interpreted languages (Python, Node, Java), scratch is not possible, but alpine reduces size by 90%.

Scanning Dockerfiles Before Build

Scan the Dockerfile itself for misconfigurations:

trivy config Dockerfile

Example output:
HIGH: Missing USER instruction (runs as root)
MEDIUM - Using latest tag instead of pinned version
LOW - No healthcheck defined

Common misconfigurations to avoid:

BAD - Runs as root
FROM alpine:latest
RUN apk add curl

GOOD - Creates non-root user
FROM alpine:latest
RUN apk add curl && \
    addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup
USER appuser

BAD - Uses latest tag
FROM ubuntu:latest

GOOD - Uses pinned version with digest
FROM ubuntu:22.04@sha256:abcdef0123456789

BAD - Single layer, large image
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y build-essential && \
    apt-get install -y nodejs && \
    apt-get install -y git && \
    apt-get clean

GOOD - Multi-stage build
FROM ubuntu:22.04 as builder
RUN apt-get update && apt-get install -y build-essential

FROM alpine:latest
COPY --from=builder /app /app

Continuous Scanning - Registry Monitoring

For production registries, scan all images weekly:

#!/bin/bash
scan-all-images.sh

REGISTRY="my-registry.com"
REPO="production"

List all images
curl -s https://${REGISTRY}/v2/${REPO}/_catalog | \
  jq -r '.repositories[]' | while read IMAGE; do

  # List all tags for this image
  curl -s https://${REGISTRY}/v2/${IMAGE}/tags/list | \
    jq -r '.tags[]' | while read TAG; do

    echo "Scanning ${IMAGE}:${TAG}"
    trivy image --severity HIGH,CRITICAL \
      "${REGISTRY}/${IMAGE}:${TAG}" \
      --exit-code 0  # Don't fail, just report
  done
done

Run via cron:

/etc/cron.weekly/docker-scan
0 2 * * 0 root /path/to/scan-all-images.sh 2>&1 | \
  mail -s "Weekly Docker Scan Report" security@example.com

Signing Docker Images

Prevent unauthorized image tampering with signatures:

Install Notary (Docker's signing tool)
Download from - https://github.com/notaryproject/notary/releases

Create signing key (you'll be prompted for a passphrase)
notary key generate --rootkey

Sign an image
notary addhash my-registry.com/myapp latest <image-digest>

Verify signature before running
docker pull my-registry.com/myapp:latest
notary verify my-registry.com/myapp

Push signed images and verify in CI/CD:

.github/workflows/verify-image.yml
- name: Verify image signature
  run: |
    notary verify my-registry.com/myapp:latest
    docker run my-registry.com/myapp:latest

Images without valid signatures are rejected during deployment.

Related Articles