This is the fifth and final post in our Docker fundamentals series, building on images/containers, production Dockerfiles, networking, and Compose.
Every Docker image is built on layers you didn't write — a base OS image, a language runtime, whatever packages got installed along the way. Every one of those layers can carry known vulnerabilities, and "the Dockerfile builds and the app runs correctly" says nothing at all about whether any of them are present. This post covers how to actually find out, and what to do about what you find.
Scanning an image
docker scout cves my-app:latest ✓ Provenance obtained from attestation
## Overview
Analyzed image my-app:latest
Vulnerabilities found 14
│ │ Total │ Critical │ High │ Medium │ Low │
├───────────┼───────┼──────────┼──────┼────────┼─────┤
│ Base image│ 12 │ 1 │ 4 │ 6 │ 1 │
│ Your code │ 2 │ 0 │ 1 │ 1 │ 0 │
docker scout (built into recent Docker Desktop/CLI installs) is one option; trivy, an open-source scanner from Aqua Security, is another widely used, CI-friendly choice:
trivy image my-app:latestBoth tools compare every package in every layer against known-vulnerability databases (the same CVE data security teams already track) and report what they find, along with severity ratings.
Reading a scan result correctly
The critical distinction in that output above — "Base image" vs. "Your code" — matters more than the raw vulnerability count. A scan reporting "14 vulnerabilities" sounds alarming until you notice that 12 of them belong to the base image, not anything your team wrote. This changes what the actual fix is:
- Base image vulnerabilities are usually fixed by updating to a newer base image tag — the vulnerability was patched upstream, and you're just behind. This is often a one-line Dockerfile change (
FROM node:20-slim→FROM node:20.15-slim, or simply rebuilding againstnode:20-slimagain if it's a rolling tag) with no code change required at all. - Vulnerabilities in your own dependencies need the equivalent of
npm audit fix,pip install --upgrade, or whatever your language ecosystem's dependency-update mechanism is — the scan told you a problem exists; fixing it is a normal dependency-update, the same as it would be outside a container.
Minimizing what there is to find in the first place
Every package that's part of an image is something that can carry a vulnerability. This directly connects back to the multi-stage build pattern from the second post in this series: a final image that only contains the compiled application and its runtime dependencies — no build tools, no compilers, no package manager cache — has meaningfully less surface for a scanner to find anything in, by construction, not by accident.
Distroless images push this further than slim or alpine bases: no shell, no package manager, no coreutils — just the application and the minimal runtime it needs.
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["dist/server.js"]The trade-off is real: no shell also means docker exec my-app sh for interactive debugging simply doesn't work — there's no shell binary present to run. Distroless is a deliberate choice for production images where the security benefit is worth losing that debugging convenience, not a default for every image in every environment.
Scanning for secrets, not just CVEs
A vulnerability scanner checking package versions against a CVE database is a different check from scanning for accidentally embedded secrets — an API key or private key copied into an image layer by a COPY . . that ignored a .dockerignore gap, covered in the production Dockerfiles post. Tools like trivy also support this as a distinct scan type:
trivy image --scanners secret my-app:latestmy-app:latest (secrets)
========================
Total: 1 (HIGH: 1)
┌──────────────────┬──────────────────┬──────────┐
│ File │ Secret Type │ Severity │
├──────────────────┼──────────────────┼──────────┤
│ app/.env.backup │ AWS Access Key ID│ HIGH │
└──────────────────┴──────────────────┴──────────┘
A finding like this is worth treating as seriously as it sounds: a secret baked into an image layer is recoverable by anyone who can pull that image, indefinitely, even after the file is deleted in a later layer — the fix is rotating the exposed credential immediately (the same "treat it as compromised the moment it's exposed" principle covered for the AWS credentials in our own CI/CD case study), not just removing the file and rebuilding.
Wiring scanning into CI, not just running it manually
A scan run occasionally, by hand, catches problems irregularly. The version that actually holds a standard is one that runs automatically, on every build, and can fail the pipeline:
# .github/workflows/scan.yml
name: Scan Docker image
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:${{ github.sha }} .
- name: Scan for critical/high vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
- name: Scan for embedded secrets
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:${{ github.sha }}
scanners: secret
exit-code: 1exit-code: 1 is the detail that makes this a real gate rather than a report nobody reads: a build with a critical or high-severity vulnerability, or an embedded secret, fails the pipeline outright, the same way a failing test would, rather than producing a report that sits in a build log no one opens until something goes wrong. This is the same "quality gate before merge" principle covered throughout our GitHub Actions series, applied specifically to what a Docker image actually contains.
Signing and verifying image provenance
Scanning tells you what's inside an image. A separate, complementary question is whether the image you're about to deploy is actually the one your own pipeline built — not a tampered or substituted one. Image signing, via tools like cosign, addresses this:
cosign sign --key cosign.key my-registry.io/my-app:latestcosign verify --key cosign.pub my-registry.io/my-app:latestA deployment pipeline that verifies an image's signature before deploying it refuses to run anything that wasn't actually signed by your own build process — closing off a category of supply-chain risk (a compromised registry, a substituted tag) that vulnerability scanning alone doesn't address, since a scanner only evaluates the contents of whatever image it's given, not whether that image is legitimately the one you meant to deploy.
Base image choice as an ongoing decision, not a one-time one
A base image that was clean when you first wrote FROM node:20-slim doesn't stay clean forever — new CVEs get discovered continuously against packages that were already sitting in that image. This is the argument for rebuilding on a schedule even when your own application code hasn't changed at all — a nightly or weekly CI job that rebuilds and rescans your image against current vulnerability data, independent of any actual feature work, catches newly-disclosed vulnerabilities in unchanged base images before they're found some other way.
# .github/workflows/rebuild-scan.yml
name: Scheduled rebuild and rescan
on:
schedule:
- cron: "0 3 * * 1" # every Monday at 03:00 UTCWhat to actually remember from this series
- Scan every image, and read the base-image vs. own-code split — most findings in a typical scan are base-image issues fixed by an update, not a code change.
- Multi-stage, minimal base images (
slim,alpine, or distroless) reduce what there is to find, by construction — this is the same principle from the production-Dockerfile post, now viewed through a security lens. - Scan for embedded secrets, not just CVEs — a leaked credential in an image layer needs rotation, not just deletion.
- Wire scanning into CI with a real failure threshold, not a report nobody reads.
- Consider signing images with a tool like
cosignto verify provenance — a different, complementary concern from vulnerability content. - Rebuild and rescan on a schedule, independent of your own code changes — vulnerabilities get discovered in base images that never changed.
That closes out our Docker fundamentals series — from the container model itself through production-ready builds, networking, Compose, and now security. If your team is standardizing container practices across real production workloads, this is exactly the kind of hands-on work we build our DevOps & Automation training around.
