Docker Image Vulnerability Scanning
Scan a deliberately old base image, read what comes back, then prove the fix: the same scan against a current base returns zero findings. Most image vulnerabilities are inherited, not written by you.
Security and Production Guide 37 of 46 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Docker Compose5.4.0
- Architectureamd64
- TimeAbout 13 min
- Reviewed22 August 2026
Tested on the versions above. Docker Scout is a CLI plugin and is not installed on this host, so the scans here use Trivy run as a container - no host packages were installed. CVE counts change daily as advisories are published; the method is what transfers.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- You can pull and inspect images - guide 6 in this path.
- Understanding of base images and layers - guide 17 in this path.
-
Check what scanner you actually have
Docker Scout is Docker's own scanner and is excellent, but it ships as a CLI plugin that is not present on every install - including this one. Rather than describe a tool that is missing, this guide uses Trivy, which runs as a container and needs nothing installed on the host.
bash docker scout versiondocker: unknown command: docker scout# not installed here - so we use a scanner that runs as a container insteadExpected resultThe plugin missing on a stock Engine install.
Success conditionYou know which tools are available to you. If
docker scoutdoes exist on your machine,docker scout cves IMAGEis the direct equivalent of everything below. -
Scan something with known problems
Scanning a current image usually returns nothing, which teaches you little. An older base image gives real findings to read. The socket mount lets the scanner read images from the local daemon; the cache volume stops it re-downloading the vulnerability database on every run.
bash docker pull -q alpine:3.16docker.io/library/alpine:3.16docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --severity HIGH,CRITICAL alpine:3.16 Report Summary ┌─────────────────────────────┬────────┬─────────────────┬─────────┐│ Target │ Type │ Vulnerabilities │ Secrets │├─────────────────────────────┼────────┼─────────────────┼─────────┤│ alpine:3.16 (alpine 3.16.9) │ alpine │ 2 │ - │└─────────────────────────────┴────────┴─────────────────┴─────────┘Legend:- '-': Not scanned- '0': Clean (no security findings detected) alpine:3.16 (alpine 3.16.9)===========================Total: 2 (HIGH: 2, CRITICAL: 0) ┌────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬────────────────────────────────────────────────────┐│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │├────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼────────────────────────────────────────────────────┤│ musl │ CVE-2025-26519 │ HIGH │ fixed │ 1.2.3-r3 │ 1.2.3-r4 │ musl libc 0.9.13 through 1.2.5 before 1.2.6 has an ││ │ │ │ │ │ │ out-of-bounds write ...... │Expected resultA summary naming the image, the OS it detected, and a count of HIGH and CRITICAL findings.
Success conditionTwo findings at HIGH or above. Filtering by severity matters - an unfiltered scan of a large image returns hundreds of LOW entries that bury anything urgent.
-
Read the report rather than the number
A count is not actionable. What matters per finding is which package is affected, which CVE it is, and - the field people skip - whether a fixed version exists. A vulnerability with no fix available cannot be resolved by upgrading, and needs a different response.
bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --severity HIGH,CRITICAL --format json alpine:3.16 | jq -r '.Results[].Vulnerabilities[] | "\(.VulnerabilityID) \(.PkgName) installed=\(.InstalledVersion) fixed=\(.FixedVersion // "none")"'# each line: the CVE, the package, what you have, and what fixes it# fixed=none means no upgrade resolves it - assess exposure insteadExpected resultOne line per finding with the fix version, or none.
Success conditionYou can separate what is fixable from what is not.
fixed=noneis not a reason to ignore it - it is a reason to judge whether that code path is reachable in your image. -
Base image or your application?
This distinction determines who fixes it. Findings against system packages come from the base image, and you fix them by moving to a newer base - not by changing your code. Findings against your language dependencies come from your manifest and are yours. Scanners report both together, and conflating them wastes time.
bash # base image findings: alpine, debian, glibc, openssl, busybox ...# -> fix by rebuilding on a newer base tag# application findings: npm, pip, gem, go modules ...# -> fix by updating your dependency manifest and rebuildingdocker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --vuln-type os alpine:3.16# --vuln-type os limits the scan to base-image packagesExpected resultA scan narrowed to operating-system packages only.
Success conditionYou can attribute a finding before deciding what to do about it. Most findings in a typical image are inherited from the base.
-
Fix it, and prove the fix
This is the step that closes the loop and it is the one most often skipped. The same scan against a current base returns zero. That is the evidence that the remediation worked - not the assumption that upgrading probably helped.
bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --severity HIGH,CRITICAL alpine:3.22 Report Summary ┌─────────────────────────────┬────────┬─────────────────┬─────────┐│ Target │ Type │ Vulnerabilities │ Secrets │├─────────────────────────────┼────────┼─────────────────┼─────────┤│ alpine:3.22 (alpine 3.22.5) │ alpine │ 0 │ - │└─────────────────────────────┴────────┴─────────────────┴─────────┘Legend:- '-': Not scannedExpected resultZero HIGH or CRITICAL findings on the current base.
Success conditionTwo findings became none by changing one line in the Dockerfile. Re-scanning after the rebuild is what turns a claimed fix into a verified one.
-
Make it fail the build
A scan nobody reads changes nothing.
--exit-code 1makes the scanner return non-zero when it finds something at or above the threshold, which fails the CI job. Start with CRITICAL only so the gate is credible, then tighten - a pipeline that fails constantly gets bypassed within a week.bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --exit-code 1 --severity CRITICAL myapp:latest# exit 1 when a CRITICAL is present, 0 when clean - the job fails on its own# gate on CRITICAL first; gating on everything produces alerts nobody acts onExpected resultA command whose exit status the pipeline can act on.
Success conditionThe scan has consequences. Pair it with an ignore file for accepted findings, reviewed on a schedule rather than forgotten.
-
Reduce what there is to find
The cheapest long-term fix is a smaller image. Every package present is something that can acquire a CVE, and a runtime image built from a multi-stage build carries almost none. Scanning is how you see the problem; image design is how you stop having it.
bash docker images --filter reference=cg-multi --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"REPOSITORY:TAG SIZEcg-multi:1 112kBcg-multi:builder 253MB# the 112kB runtime image has no shell and no package manager to report onExpected resultThe runtime image orders of magnitude smaller than the builder.
Success conditionYou can connect image size to how much there is to scan. See guide 19 for how that image was produced.
-
Clean up
Remove the deliberately old image and the scanner's cache volume so neither lingers.
bash docker rmi alpine:3.16docker volume rm trivy-cache# and docker rmi aquasec/trivy:latest if you do not intend to scan again soonExpected resultThe image and cache volume removed.
Success condition
docker volume lsno longer lists the cache. Keep the scanner image if you scan regularly - it saves the pull each time.
Troubleshooting
The scanner reports no vulnerabilities for an image you expect to be vulnerable
Why: Severity filtering hid them, or the vulnerability database is stale, or the image OS is not one the scanner recognises.
Fix:Run without a severity filter first, and make sure the database updated - a cache volume shared across runs can hold an old copy.
bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --quiet myapp:latestCannot connect to the Docker daemon from inside the scanner
Why: The socket was not mounted, so the scanner cannot read locally built images.
Fix:Mount the socket, or scan from a registry reference instead, which needs no socket at all.
bash docker run --rm aquasec/trivy:latest image registry.example.com/team/app:1.0A finding has no fixed version
Why: No patched package has been released yet for that distribution.
Fix:Judge exposure rather than waiting: is the affected code reachable in your image, and can you remove the package entirely? Record the decision so it is reviewed rather than forgotten.
bash docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --ignore-unfixed myapp:latest# --ignore-unfixed shows only what you can act on todayThe same findings reappear after every rebuild
Why: The base image tag is pinned to a version that no longer receives updates, or the build is using a cached base layer.
Fix:Move to a current base tag and pull it fresh during the build.
bash docker build --pull -t myapp:latest .