Lockfiles & npm ci
why npm install and npm ci are NOT the same
Developer toolingdeveloper tooling / package management
The myth
"I committed package.json, so my builds are reproducible."
Actually
package.json holds ranges ("^4.17.1"), not exact versions. The exact tree that actually got installed lives in the lockfile (package-lock.json / yarn.lock / pnpm-lock.yaml). Without it — or if you ignore it — two machines can resolve the same range to different versions.
Example range:
"express": "^4.17.1"
^ means "any 4.x ≥ 4.17.1". Six months later a fresh install might pull 4.19.2. Your CI passes, your teammate's fails, and nothing in package.json changed.
What the lockfile does
- Pins every dependency AND every transitive dependency to an exact version + integrity hash.
- Records the resolved tree, so installs can skip re-resolving.
Commit it. Always — apps and libraries alike (yes, libraries too, for your CI's sake).
The gotcha: install mutates
npm install is allowed to update the lockfile — add a missing sub-dep, re-resolve a range, "fix" it. Run it in CI and you may silently ship different versions than you tested, plus get a dirty git tree.
npm ci (clean install):
- Requires an existing lockfile and errors if it's out of sync with package.json.
- Deletes node_modules, installs the EXACT locked tree.
- Never writes the lockfile.
- Faster in CI (no resolution step).
Rule of thumb:
- Local dev, adding a dep → npm install
- CI / Docker / production build → npm ci
Concrete difference
# Dev machine, Jan
npm install # resolves ^4.17.1 → 4.17.1, writes lockfile
# CI, June (lockfile committed)
npm ci # installs exactly 4.17.1, hash-verified, ignores newer 4.19.x
Same input, same output. That's the whole point.
Common mistakes
- .gitignore-ing the lockfile "because it's generated."
- Using
npm installin Dockerfiles → non-reproducible images. - Hand-editing the lockfile (regenerate instead).
- Mixing package managers (npm + yarn) → two competing lockfiles fighting.
Your exercise
Run git status right after npm install in a repo — did package-lock.json change? Now bump a version in package.json WITHOUT updating the lock, and run npm ci. It should refuse to install. That error is the feature: it's catching the drift you'd otherwise ship.