← Back to Node Version Drift Check

Why Node.js versions drift between package.json, .nvmrc, Docker and CI

A Node.js project states which runtime it wants in up to five places: engines.node in package.json, an .nvmrc or .node-version file, an asdf .tool-versions line, the FROM node: line of a Dockerfile, and the node-version of a GitHub Actions actions/setup-node step (Volta users add volta.node). Each is read by a different tool. None of those tools reads the others.

The four kinds of drift

FindingExampleWhat goes wrong
Pin violates engines.nodeengines.node is >=22, .nvmrc says 20.11.1Local development runs a Node.js your own package says it does not support; installs warn, or fail where engine-strict is on.
Majors disagreeDockerfile on node:22, CI on node-version: 20Tests pass on one major and production runs another.
End-of-life Node.jsA CI matrix still lists 18The line no longer receives security fixes, and you are testing a version you should not be shipping.
Unbounded engines>=18 and no pin anywhereNo file records which version was ever tested.

Reading the end-of-life table

The dates come from the Node.js release schedule at nodejs.org/en/about/previous-releases: 18 ended 2025-04-30, 20 ended 2026-04-30, 22 ends 2027-04-30 and 24 ends 2028-04-30. Odd majors are never long-term support and end about six months after release. The table inside the tool is dated 2026-10-11 and is only a snapshot, which is why every finding says "per the table dated" and why --today exists: it makes the verdict reproducible in a test.

Run it in CI

node cli.mjs --fail-on-risk

Exit status 2 means at least one risk finding. Notices (aliases the tool will not guess at, an engines range that still admits an end-of-life major, a major that ends within 90 days) are printed but do not fail the run.

Common questions

Why do Node.js versions in a repo drift apart?

Each file is edited for a different reason by a different person. Someone bumps the Dockerfile for a security fix, someone else raises engines.node when a dependency needs it, and the .nvmrc and the CI workflow are only touched when something breaks. Nothing in npm, Docker or GitHub Actions compares them to each other, so the disagreement lasts until a bug makes it visible.

What does an unbounded engines.node range actually risk?

A range like >=18 says every future major will do. It will happily install on a Node.js version released next year that none of your dependencies or tests have ever run on, and with nothing pinned in .nvmrc, the Dockerfile or CI there is no file that says which version you actually tested. A bounded range such as >=22 <25, or a pin, records the versions you stand behind.

Why are odd-numbered Node.js versions treated differently?

Odd-numbered releases never enter long-term support and stop receiving fixes roughly six months after release, so the table marks their dates as approximate. Even-numbered releases become long-term support and are maintained until April 30 of their third calendar year. A pin on an odd major in a Dockerfile or CI is therefore usually a leftover rather than a decision.

What does this checker deliberately not do?

It does not run Node.js, install packages, or ask any registry which versions exist. It cannot tell you what lts/* or latest mean today, so those are listed as notices. It treats prerelease tags as their release, only reads the first nodejs line of a .tool-versions file, and judges end-of-life from a static table that you should confirm against nodejs.org before acting on a finding.