Best practices

How to Reduce Docker CVEs: 6 Steps That Work

Published July 7, 2026 · 9 min read

You scanned your image, the report says 900 findings, and your security team wants the number down. This guide walks through the six changes that actually reduce Docker CVEs, in the order of effort-to-impact, with runnable examples for each.

Reducing CVEs in Docker images

Where Docker CVEs actually come from

Before changing anything, understand the shape of the problem. In a typical application image, the overwhelming majority of findings are not in your code or even your dependencies — they are in the operating system packages of the base image. Every package in the base carries its own advisory history: the C library, package managers, shells, TLS libraries, compression tools.

The numbers make the point. When we scanned nginx:1.27 (Debian-based) it produced 2,952 findings; the same nginx on the Alpine base produced 619 (ScanRook v1.14.2, warm-cache scan, 2026-07-04). Same web server, 79% fewer findings, purely because the base contains less software. That asymmetry drives the order of the steps below: shrink the operating system first, then patch what remains, then keep it patched.

Step 1: Move to a smaller base image

The single highest-impact change. Every package you remove from the image is a package that can never appear in a scan report again. For most runtimes there are three tiers: the full distribution image, a slim/Alpine variant, and distroless.

# Before: full Debian base, complete GNU userland
FROM node:22

# Better: Debian slim — drops docs, most tooling
FROM node:22-slim

# Better still: Alpine — musl libc + BusyBox userland
FROM node:22-alpine

# Smallest: distroless — no shell, no package manager
FROM gcr.io/distroless/nodejs22-debian12

Each tier trades convenience for surface: Alpine swaps glibc for musl (a compatibility concern for native modules), and distroless removes the shell entirely (a debugging concern). Our Alpine vs Debian vs Distroless comparison covers the tradeoffs in depth. If you can only make one change from this article, make this one.

Base image choice, measured — ScanRook v1.14.2, 2026-07-04

Horizontal bar chart of total ScanRook findings per image: busybox 1.37 has 2, alpine 3.20 301, node 22-alpine 306, nginx 1.27-alpine 619, nginx 1.27 2,952, node 22 30,726CriticalHighMediumLowNo severity assignedbusybox:1.372 total · 0 criticalalpine:3.20301 total · 20 criticalnode:22-alpine306 total · 23 criticalnginx:1.27-alpine619 total · 84 criticalnginx:1.272,952 total · 408 criticalnode:2230,726 total · 1,794 critical
Base image choice is the largest single lever in this article: node:22 reports 30,726 total findings (1,794 critical) against 306 (23 critical) for node:22-alpine, and nginx:1.27 reports 2,952 (408 critical) against 619 (84 critical) for nginx:1.27-alpine — same application, smaller base. Alpine is not empty either: alpine:3.20 alone reports 301 (20 critical), so Steps 2 through 6 still matter. Switching bases shrinks the surface by orders of magnitude; it does not eliminate it. Bar length is linear in total findings, so the smallest bars are only a few pixels wide — exact totals are printed at right. The four rated buckets do not always add up to the total because some advisories carry no CVSS severity; that remainder is the unfilled part of each bar. The busybox:1.37 scan was partial — its runtime package inventory was unavailable, so matching fell back to heuristics. busybox genuinely is minimal, but treat 2 as a floor rather than a verified complete count.

Step 2: Rebuild with a fresh base — regularly

Base images are rebuilt upstream as patches land, but your image is frozen at the moment you built it. An image built six months ago is carrying six months of advisories that are already fixed upstream. Two flags matter:

# --pull: re-fetch the base image even if cached locally
# --no-cache: rebuild every layer so package installs re-run
docker build --pull --no-cache -t myapp:$(date +%Y%m%d) .

Put this on a schedule — weekly is a reasonable default, nightly for internet-facing services. A scheduled CI job that rebuilds, scans, and redeploys is the difference between a CVE count that decays continuously and one that only improves when someone remembers.

Step 3: Apply OS security updates in the build

Even a freshly pulled base can lag the distribution's security feed by days. Applying updates during the build closes that gap:

# Debian/Ubuntu bases
RUN apt-get update \
    && apt-get upgrade -y \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Alpine bases
RUN apk upgrade --no-cache

The cleanup lines matter twice over: they shrink the image, and removing the APT list cache means stale metadata cannot linger in a layer. If your organization requires byte-reproducible builds, pin the base by digest and treat the deliberate rebuild in Step 2 as the update mechanism instead.

Step 4: Use multi-stage builds to drop the toolchain

Compilers, dev headers, git, curl, and build tools are prime CVE carriers, and none of them belong in the final image. Multi-stage builds let you compile in a fat image and ship from a thin one:

# Stage 1: build with the full toolchain
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

# Stage 2: ship only the artifact
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

The final image here contains one binary and CA certificates — no compiler, no package manager, no shell. The same pattern works for Node (build stage runs npm ci and bundling, runtime stage copies dist/ and production node_modules) and for Python with wheels built in the first stage.

Step 5: Stop installing what you do not need

Debian's package manager installs “recommended” packages by default, which quietly pulls in software you never asked for — each with its own advisory history. Disable that, and audit what you install explicitly:

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        libpq5 \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

Common stowaways worth removing: curl and wget (use build-stage downloads instead), openssh clients, perl and python pulled in as dependencies of tooling, and debug utilities added “temporarily” during an incident. If a package does not execute in production, it is pure scan-report liability.

Step 6: Update your application dependencies

After the OS layer is under control, what remains is your dependency tree — npm, pip, Go modules, Maven. These findings are usually the most actionable, because fixes are published as ordinary version bumps:

# Node: report, then apply non-breaking fixes
npm audit
npm audit fix

# Python: upgrade pinned requirements deliberately
pip list --outdated
pip install -U <package>==<fixed-version>

# Go: patch-level module updates
go get -u=patch ./... && go mod tidy

Update lockfiles in the same commit as the rebuild so the scan result maps cleanly to a single change. Automated dependency-update tooling (Renovate, Dependabot) turns this step into review work instead of research work.

Verifying it worked

Measure, do not assume. Scan the image before your changes, apply the steps, and scan again with the same scanner version:

curl -fsSL https://scanrook.io/install.sh | sh

docker save myapp:before -o before.tar
scanrook scan --file before.tar --format json --out before.json

docker save myapp:after -o after.tar
scanrook scan --file after.tar --format json --out after.json

# Compare severity totals
jq '.summary' before.json after.json

Expect the distribution of what remains to shift, too: after Steps 1–5 the leftover findings should be concentrated in your application dependencies (fixable via Step 6) and a small tail of no-fix-available OS advisories, which are triage candidates rather than fix candidates. If a finding survives every step, verify whether the package is even reachable at runtime — our piece on installed-state scanning explains how scanners decide what is really present.

Where ScanRook fits

Reducing CVEs is a loop, not a project: rebuild, scan, compare, fix, repeat. ScanRook is built for that loop — scan a saved image tar locally or in CI, get JSON with severity totals you can gate builds on, and use confidence tiers to separate findings in packages that are verifiably installed from heuristic matches. Start with the scanning guide or the docs for CI recipes.

Frequently asked questions

What is the fastest way to reduce CVEs in a Docker image?

Switch to a smaller base image. It removes hundreds of findings in one change because most findings live in OS packages you never use.

Does rebuilding an image reduce vulnerabilities?

Yes — your image only picks up base patches when you rebuild with --pull. Schedule it weekly at minimum.

Should I run apt-get upgrade in my Dockerfile?

For security patches, yes. If you need reproducible builds, pin the base by digest and rely on scheduled rebuilds instead.

Why do CVEs remain after updating everything?

Some advisories have no released fix, and some packages will not be patched on older distribution releases. Triage those by reachability, or move to a base that does not ship the package.

Measure the reduction with ScanRook

Scan before-and-after builds, diff severity totals, and gate CI on the result. ScanRook matches every installed package against OSV, NVD, and vendor advisory data so the number you report to your security team is one you can defend.

Related Posts

More on this topic.