Commit the Lockfile, Build with npm ci: A Practical npm Decision
Why committing package-lock.json and building with npm ci is one of the highest-leverage decisions in a JavaScript repo — and where npm install still belongs.
08 Aug 2025, 05:47 UTC

Your CI passed on Tuesday and failed on Wednesday with no commit in between. Nothing in the repo changed — but a transitive dependency published a new patch, the range in your package.json happily accepted it, and a fresh install on a clean checkout, with no lockfile to hold it to what you tested, pulled in code you never chose. If that sounds familiar, the fix is a decision rather than a tool: commit package-lock.json, build with npm ci, and treat the lockfile as a reviewed artifact instead of machine noise.
That's the thesis. The rest is the reasoning: what the lockfile records, where npm install still belongs, a CI setup you can copy, and the trade-offs to know before you standardize on npm ci.
What the lockfile actually records
package.json expresses intent: "zod": "^3.23.0" means any 3.x release from 3.23.0 upward. package-lock.json records the resolution: the exact version chosen for every direct and transitive dependency, an integrity hash npm uses to verify each downloaded tarball, and where it was resolved from. The two files answer different questions — what I allow versus what I actually got.
Committing the lockfile turns dependency state into something you can diff. A pull request that bumps one library shows every transitive package that moved with it — exactly the review surface that invisible npm install runs hide.
npm install vs npm ci: different jobs, not rivals
The two commands overlap just enough to cause most reproducibility problems.
| npm install | npm ci | |
|---|---|---|
| Reads | package.json ranges, reconciled with the lockfile | package-lock.json only |
| Writes package.json / lockfile | Yes — that's its job when dependencies change | No |
| Existing node_modules | Updated in place | Deleted, then reinstalled from scratch |
| Manifest and lockfile disagree | Resolves a new tree and rewrites the lockfile | Fails fast with an error |
| Best for | Adding or upgrading dependencies, local iteration | CI, Docker builds, deploys |
The opening failure happens when CI runs npm install on a checkout with no committed lockfile: npm resolves the ranges fresh, and any newly published compatible release slips in. npm ci refuses to guess. If the lockfile is missing or disagrees with package.json, it exits with an error instead of quietly building a different tree.
A version note: this describes npm 8 through 10 on current Node LTS lines; npm ci itself dates to npm 5.7. Flags and error messages drift between release lines, so check npm help ci on the version your CI actually runs.
A worked example: CI that can't drift
Here's a minimal GitHub Actions workflow. It runs on GitHub-hosted runners; the same shape works in GitLab CI or a Dockerfile with the npm line swapped.
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # caches the download cache, keyed on the lockfile
- run: npm ci
- run: npm testThe useful part is what happens when someone breaks the invariant. Say a teammate hand-edits package.json to add a dependency but forgets to run npm install, so the lockfile never updates. npm ci fails within seconds with a usage error saying the files are out of sync and pointing to npm install as the fix. That is a fast, obvious failure at the install step — far better than meeting the same problem as a module-not-found error deep in the test run, or not meeting it at all because npm silently resolved something else.
The trade-offs and limits
- Repeatable is not the same as safe. The lockfile pins what you downloaded, integrity hashes included — that guards against corrupted or tampered transfers, not against a bad package being published in the first place. A clean
npm cisays nothing about licenses or install scripts, so review lockfile diffs like code. - Registries complicate "identical." Mirrors, proxies, and private scoped packages mean the same lockfile can behave differently when registry configuration differs. Keep
.npmrcconsistent across machines and CI. - Libraries are a special case. Consumers of a published package install from the ranges in your
package.json, not your lockfile. Committing it still helps contributors and your own CI, but it pins nothing for downstream users. Applications should always commit. npm ciis deliberately wasteful locally. Deletingnode_moduleson every run is a feature in CI and a nuisance day to day. Keepnpm installfor that.
How to verify your setup
- In a scratch clone, delete
node_modulesand runnpm ci. The install should complete from the lockfile alone, andnpm lsshould match your working copy's tree. - Edit
package.jsonwithout regenerating the lockfile, then runnpm ci. You should see the out-of-sync failure, not a successful install. - Bump a dependency with
npm install package@versionand read the lockfile diff: exact version and integrity changes, including any transitive packages that moved. - Run the build locally and in CI on the same Node and npm versions and compare
npm lsoutput.
The decision, condensed
If your CI still runs a bare npm install, change it today: commit the lockfile, swap the install step to npm ci, and start reading lockfile diffs in code review. Keep npm install for the moments you intend to change dependencies — and let npm ci enforce that nothing else does.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.