Why CI should run `npm ci`, not `npm install`
In a CI pipeline, you should run npm ci, not npm install. The difference is small in syntax and large in consequences.
What each command does
npm install reads package.json, consults the registry, computes a dependency tree, and installs it. If package-lock.json exists, npm uses it as a starting point but may update it if package.json has changed or if newer versions inside declared ranges are available.
npm ci requires both package.json and package-lock.json to exist and be consistent with each other. It deletes any existing node_modules and installs strictly from the lockfile. No re-resolution, no lockfile updates, no surprises.
The "ci" in the command name is short for "clean install." It is designed for environments where reproducibility and predictability matter more than convenience.
Reproducibility
Two CI runs on the same commit should produce the same node_modules. With npm install, that property is not guaranteed: between the two runs, a transitive dependency may have published a new patch version that fits the declared range, and npm may pick it. The resulting builds are not byte-identical.
With npm ci, both runs install exactly the versions and hashes the lockfile records. If a tarball's content has changed (an attacker overwriting a published version, in the rare case where a registry permits this), the recorded integrity hash does not match and the install fails. The lockfile is the contract; npm ci enforces it.
Performance
npm ci is typically faster than npm install in CI. Two reasons:
- No resolution work. The lockfile already specifies what to install; npm just downloads and unpacks.
- Predictable network behavior. The fetcher knows exactly which tarballs to pull.
In CI with cold caches, the difference is usually 20–40%. With warm caches the gap narrows.
Lockfile discipline
npm ci exits with an error if package.json and package-lock.json disagree. This makes it impossible to merge a package.json change without the corresponding lockfile update — the CI build will fail.
That property catches a common mistake: a developer adds a dependency in package.json, runs the application locally with npm install (which silently updates the lockfile), but commits only the package.json change. CI runs npm install and similarly succeeds because it also updates the lockfile silently — and now CI's lockfile differs from local's. Move to npm ci and this class of drift disappears.
Security implications
Three security properties improve when CI uses npm ci.
Integrity verification is enforced. Every package in the lockfile has a sha512 integrity hash. npm ci verifies each tarball against its recorded hash on install. Tampering between publication and install fails the build.
No silent dependency upgrades. A transitive dependency that publishes a new version mid-build does not get installed. You install only what was reviewed and committed in the lockfile.
Lockfile diffs become reviewable artifacts. Because npm ci is the only path to install, any change in the lockfile is intentional. The lockfile is part of the change set, and code review can see what's changing.
When npm install is correct
For local development on a developer machine, npm install is fine. You are deliberately resolving fresh, you can react to changes, and your node_modules is disposable.
For initial project setup, where there is no lockfile yet, you have to use npm install to produce one. Commit the lockfile after.
When intentionally upgrading dependencies, npm install <pkg>@<range> (or npm update) is the right command — you are choosing to re-resolve. The result is a lockfile diff to review.
The rule is simple: npm install to change what's pinned, npm ci to install what's pinned. CI does the latter.
Equivalent commands in other ecosystems
- yarn:
yarn install --frozen-lockfile(Yarn 1) oryarn install --immutable(Yarn 2+). Same semantics: install strictly fromyarn.lock, fail if it disagrees withpackage.json. - pnpm:
pnpm install --frozen-lockfile. Same idea. - pip:
pip install -r requirements.txt --require-hashes(with hashes generated bypip-compile --generate-hashes). This is the closest functional equivalent. - uv:
uv sync --frozeninstalls fromuv.lockwithout re-resolving. - Poetry:
poetry installreadspoetry.lockand uses it strictly by default.
Takeaway
npm ci is a small change with outsized benefits: faster builds, deterministic outputs, integrity-verified installs, and a forced discipline that keeps package.json and package-lock.json in sync. Switch your CI workflow today; revisit any custom shell that runs npm install as a leftover from older docs.