is-there-a-clean-node-image.md
2026-11-08 | 6 min read

Is There a Clean Node.js Image? (No — and the Zeros Lie)

DockerSecurityNode.jsDevOps

Every few months somebody on a team asks the same question: “which Node base image should we use, the one with the fewest CVEs?” It sounds like a question with a number for an answer. So I went and got the numbers. All of them.

The short answer: no Node.js image is clean. The longer answer is more useful, because the images that looked clean were the ones lying to me.

What I scanned

On 7 November 2026 I scanned 31 images, all linux/amd64, with Trivy 0.76.2 and Grype 0.121.2, both with vulnerability databases from that same day:

  • Docker Official node images for every supported line: 22 (Jod), 24 (Krypton) and 26 (Lithium, LTS since 28 October). Default, -bookworm, -slim, -bookworm-slim, -alpine3.24 and -alpine3.23.
  • Google distroless nodejs22/24/26 on Debian 12 and 13.
  • Chainguard node:latest and latest-dev.
  • Red Hat UBI 9 and UBI 10 nodejs-22/24-minimal.
  • Bitnami node:latest.

Node 20 is out on purpose. It went end-of-life on 30 April 2026, and if you are still shipping it, the CVE count is not your problem.

Two scanners, because they use different databases and disagree, and the disagreement is half the story.

The numbers

Counts are unique CVE-per-package, as reported by Trivy. “Fixable” means a fixed version exists.

ImageBaseFindingsFixable
node:22, node:24Debian 12~4,02019–23
node:26Debian 132,29625
node:2x-slimDebian208–23819–23
node:2x-alpineAlpine20–2420–24
distroless nodejs2x-debian12Debian 128536
distroless nodejs2x-debian13Debian 1345–503
Chainguard node:latestWolfi00
UBI 9 nodejs-2x-minimalRHEL 9382–5020
UBI 10 nodejs-2x-minimalRHEL 100—

Look at that table for five seconds and you will draw the wrong conclusion. Let me show you why.

Lie #1: a zero that never ran

My very first Grype run reported zero vulnerabilities. For everything. Grype ran in a container, wrote its database into a home directory that died with the container, and happily matched every image against nothing.

It didn’t fail. It printed a clean result.

TIP: Before you trust any scan, run a positive control — an image that must have findings. I used the end-of-life node:20-slim. If the control comes back clean, your scanner is broken, not your image.

Lie #2: UBI 10 is not clean, Trivy just can’t see it

Trivy reports 0 findings on UBI 10. It detects RHEL 10.2, lists 105 packages, and matches none of them. Grype, using Red Hat’s own data, finds roughly 440–510 on the same images.

The control is UBI 9: same Node build, and there Trivy does flag the same CVE-2025-64756 on nodejs and npm. So the UBI 10 zero doesn’t mean “clean”. It means “not covered”.

Lie #3: old Alpine gets cleaner as it rots

This one is nasty. Alpine publishes advisories for supported branches. When a branch reaches end-of-life, the advisories stop, and the scanner has nothing to match against:

ImageAlpine EOLTrivy findings
alpine:3.14May 20230
alpine:3.19November 202510 (2 HIGH)
alpine:3.24June 20281

The oldest, least-patched image scores best. Trivy does warn that the OS is “no longer supported”, but only in the log, never in the count. Any gate that reads the number cannot see it.

And this isn’t theoretical. redis:7-alpine sits on Alpine 3.21, which went EOL on 1 November 2026. It reports one finding. That number is no longer measuring anything.

Lie #4: comparing counts across distributions

4,020 vs 20 looks like Debian is two hundred times worse than Alpine. It isn’t.

The Debian security tracker records every CVE that affects a package for the life of the release, including the ones it decided not to fix (no-dsa, ignored, postponed). Alpine mostly doesn’t list those at all. A bigger Debian number is partly just more honest reporting.

So you should compare counts only within one distribution. Across distributions, compare what is fixable and how current the image is. Out of ~4,020 findings in node:22, about 20 have a fix. That’s the number worth your attention.

Lie #5: “critical” depends on who you ask

The single OS finding in the Alpine images is zlib CVE-2026-4738. Trivy rates it MEDIUM (Alpine’s rating). Grype rates it CRITICAL (CVSS). Same CVE, same package, same image.

If you have a “fail the build on critical” gate, its answer depends on which scanner feeds it. Name the source in the gate, or the gate means nothing.

NOTE: Alpine already fixed it in zlib 1.4-r0 for both 3.23 and 3.24. The Node images were simply built before that landed. It goes away with the next rebuild.

Where the fixable findings actually live

Here’s the part I didn’t expect. In the official node images, every fixable finding comes from the same place:

/usr/local/lib/node_modules/npm

npm’s own bundled dependencies — undici, brace-expansion, ip-address, glob, minimatch and so on. Not Node, not the OS.

Do you need npm in your production container? You don’t. Remove it in the final stage of the build:

# runtime stage: the app needs node, not a package manager
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx

That alone takes node:2x-alpine from 20 findings to 1. It does more than choosing between base images does.

So what should you use?

There isn’t a clean image. But by fixable findings on a supported base, three of them come out at or near zero: Alpine, Chainguard and distroless on Debian 13. Choose between them by things other than the count:

  • Distroless nodejs2x-debian13 — my pick. No npm in the image at all, glibc so native addons behave like on Debian, Node major pinned in the tag and the exact build pinnable by digest. No shell, so debugging needs the :debug tag. Avoid the -debian12 variants: the distroless README lists only Debian 13 Node images and says other tags are deprecated and no longer updated, which is why they lag.
  • Chainguard — lowest numbers, but the free tier is latest and latest-dev only. Version pinning is a paid feature. For production, a runtime you can’t pin is a bigger risk than one medium CVE.
  • Alpine — perfectly fine while you stay on a supported branch. Put the branch’s EOL date in your calendar, because the scanner won’t tell you.

For Redis, where there’s no distroless option, the fix is boring: move from redis:7-alpine (Alpine 3.21, EOL) to redis:8-alpine (Alpine 3.23, supported until November 2027). Funny enough, the count stays at one. The difference is that now the one actually means something.

Conclusion

So, is there a clean Node image? No. And if your scanner says there is, I’d check whether it actually looked.

To cut the long story short:

  • Run a positive control before trusting any zero.
  • Compare fixable findings, not totals, and never compare totals across distributions.
  • Watch the EOL date of the base, because the scanner goes quiet when it passes.
  • Remove npm from the runtime image. It’s the cheapest fix you will find.

A clean dashboard is not a clean image. A measured number you understand beats a zero you don’t.

References

[0] made by wanted.solutions *bash
~/blog/is-there-a-clean-node-image
git blame: someone else 04:20 CET