A CI job should install the dependency tree you committed. npm ci is the npm command built for that job, and its most useful behavior is a refusal. When package.json and package-lock.json disagree, it stops with an error. It does not rewrite the lockfile to make them agree. That catches a manifest edited without a fresh lockfile, including an edit by a coding agent that changed package.json and never ran an install.
What the npm documentation promises
The npm ci documentation describes the command as “similar to npm install, except it’s meant to be used in automated environments such as test platforms, continuous integration, and deployment.” It lists five differences from npm install:
- The project must have an existing
package-lock.jsonornpm-shrinkwrap.json. - If dependencies in the lockfile do not match those in
package.json,npm ciexits with an error instead of updating the lockfile. - It installs whole projects only. You cannot add a single dependency with it.
- If
node_modulesalready exists, it is removed before the install begins. - It never writes to
package.jsonor any lockfile. The docs call installs “essentially frozen.”
The second and fifth points are the reason to use it in CI. A build should test the tree you reviewed. npm ci installs that tree or stops.
Why the lockfile is the contract
The package-lock.json documentation says the file “describes the exact tree that was generated, such that subsequent installs are able to generate identical trees, regardless of intermediate dependency updates.” The same page says the file is generated for any operation where npm modifies node_modules or package.json. That is the risk with npm install in a pipeline. It is allowed to change the lockfile, and a changed lockfile in a CI runner is a tree nobody committed.
The failure you will meet
The common case is a hand edit. Someone adds a dependency to package.json, commits, and pushes. Nobody regenerated the lockfile.
{
"dependencies": {
"express": "^4.19.0",
"zod": "^3.23.0"
}
}
If package-lock.json has no entry for zod, the two files disagree and npm ci exits with an error. The same happens when a version range in package.json changes and the locked version no longer satisfies it. This is the command doing its job. The docs do not print the exact error text, so do not script against its wording. Treat the failed step as the signal.
How to fix it
Fix the mismatch on a developer machine, where changing the lockfile is the point:
npm install
git add package.json package-lock.json
git commit -m "Add zod to the lockfile"
CI then runs npm ci against the corrected pair. Do not switch the CI step to npm install to make the error go away. That brings back a pipeline that can write a lockfile nobody committed.
A minimal CI step
The npm docs show a Travis CI configuration. The same steps in plain shell work in any CI system:
npm ci
npm test
The docs’ example keeps $HOME/.npm, the npm cache, between builds “to speed up installs.” Caching node_modules itself gains little with npm ci, because the command removes that folder before it installs.
Two configuration traps
Tree-shaping flags must match. The docs warn that if you created the lockfile with flags that affect the shape of the dependency tree, such as --legacy-peer-deps or --install-links, “you must provide the same flags to npm ci or you are likely to encounter errors.” Their suggested fix is to store the setting in project config and commit the file:
npm config set legacy-peer-deps=true --location=project
git add .npmrc
Local installs and CI now read the same setting.
NODE_ENV=production drops dev dependencies. The omit option’s default is dev when the NODE_ENV environment variable is set to production. The docs say omitted packages are still resolved and recorded in the lockfile but are not installed on disk. If your CI sets NODE_ENV=production before npm ci and then runs a test runner from devDependencies, the runner will be missing. Set NODE_ENV after the install, or pass --include=dev. The docs say a type listed in --include is installed even if --omit lists it.
Where this advice stops
npm cineeds a lockfile. A project withoutpackage-lock.jsonornpm-shrinkwrap.jsoncannot use it until someone runsnpm installand commits the result.- It cannot add or upgrade packages. Do that locally with
npm install. - If both
package-lock.jsonandnpm-shrinkwrap.jsonsit in the project root, the lockfile docs saynpm-shrinkwrap.jsontakes precedence andpackage-lock.jsonis ignored. Make sure you are editing the file npm reads. - A lockfile makes installs reproducible. It says nothing about whether the pinned versions are safe to run. That needs its own review.

The Campfire
No commentsNobody has pulled up a log by this one yet. Be the first to say what you make of it.
Held for the desk. It appears after a look.