Reproducible npm installs with package-lock.json and npm ci
Commit package-lock.json and use npm ci in CI to prevent dependency drift between local and production installs.
13 May 2026, 07:12 UTC

The problem and the takeaway
If your continuous integration pipeline runs `npm install`, the dependency tree on the build server can differ from the tree you tested locally. `package.json` declares semantic version ranges, which permit patch and minor updates. A new release published between your local install and the CI build changes `node_modules` without changing `package.json`.
The practical fix is to commit `package-lock.json` to version control and run `npm ci` in CI. Use `npm install` locally to update the lockfile when dependencies change, and use `npm ci` in automation to install exactly the locked tree and fail if the lockfile is missing or out of date.
Requirements
- Identical dependency versions across local, staging, and production builds.
- Fast, deterministic installs in CI pipelines.
- Early detection of dependency drift before deployment.
- Explicit Node.js version requirements.
- npm 5 or later, which introduced `package-lock.json` and `npm ci`.
Smallest suitable design
Commit `package.json` and `package-lock.json`. Add `node_modules` to `.gitignore` because it is a derived artifact, not source code.
Locally, run `npm install` in the project root. This reconciles `node_modules` with `package.json` and updates `package-lock.json` if a new version satisfies a declared range. In CI, run `npm ci` instead. It reads the lockfile, aborts if it is absent or out of sync with `package.json`, and performs a clean install by removing any existing `node_modules` first.
Declare the runtime in `package.json` so the lockfile is not the only version guard:
{
"name": "example-service",
"version": "1.0.0",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"dependencies": {
"express": "^4.19.2"
}
}
A corresponding `package-lock.json` records the resolved `express` version, its transitive dependencies, and integrity checksums. The lockfile is machine-generated; do not edit it by hand.
Trust and data boundaries
- package.json: human intent expressed as semver ranges.
- package-lock.json: machine-generated exact dependency tree; the trusted source of truth in CI.
- node_modules: derived output; never commit it.
- npm registry: external boundary. The lockfile pins versions and checksums, but it does not guarantee registry availability or protect against a compromised package published under the same version in some registry configurations.
Operational checks
Run these commands in the project root as a normal user. Avoid `sudo` with npm, which can change file ownership and cause later permission failures.
Confirm the installed tree matches the lockfile
npm ls express
Expected output shows the installed version and the version recorded in `package-lock.json`. If they differ, run `npm install` to resynchronize.
Detect lockfile drift before committing
npm install
git diff --exit-code package-lock.json
A non-zero exit code from `git diff` means `npm install` changed the lockfile. Review the diff, then commit the change or revert it with `git restore package-lock.json` and reinstall.
Simulate CI locally
rm -rf node_modules
npm ci
Risk: this deletes `node_modules`. Expected result is a clean install with exit code zero. If `npm ci` aborts, the lockfile is out of date or missing.
Check the Node.js runtime
node --version
Compare the result against the `engines.node` range. npm warns on mismatch; set `engine-strict=true` in `.npmrc` to make it a failure.
Comparison: npm install versus npm ci
| Command | Typical use | Lockfile behavior | node_modules behavior |
|---|---|---|---|
| `npm install` | Local development | Updates if package.json changes | Installs or updates the existing tree |
| `npm ci` | CI and automation | Aborts if missing or out of date | Removes and recreates a clean tree |
Failure modes
- Missing or stale lockfile: `npm ci` exits with an error. Fix by running `npm install` locally, committing the updated `package-lock.json`, and rerunning CI.
- Hand-edited lockfile: Manual edits can break checksums or dependency resolution. Regenerate with `npm install` instead of editing directly.
- Runtime mismatch: The lockfile does not lock Node.js. A build may succeed on the wrong Node version if `engines` is absent or not enforced.
- Destructive install: `npm ci` deletes `node_modules`, so it is unsuitable for incremental local work where you want to preserve an existing tree.
Conditions that would change the design
- Monorepo: Use npm workspaces so a single root install symlinks workspace packages, rather than maintaining separate lockfiles for each package.
- Transitive dependency conflicts: If a dependency of a dependency needs a different version, update `package.json` and regenerate the lockfile; do not patch `package-lock.json` manually.
- Lockfile merge conflicts: Resolve conflicts in `package.json`, then run `npm install` to regenerate `package-lock.json` from the merged manifest.
- Offline or air-gapped CI: A lockfile alone is not sufficient. You also need a mirrored registry or vendored dependencies because `npm ci` must fetch packages.
Limitations and verification
`package-lock.json` locks dependency versions, not the npm client version. Different npm major versions can produce different lockfile formats. Pin the npm version in CI through your container image or tool installer, not through the lockfile.
Practical verification: after `npm ci`, run `npm ls express` for each top-level dependency and confirm the versions match `package-lock.json`. In CI, treat a non-zero `npm ci` exit code as a build failure, and do not fall back to `npm install` to bypass it.
Rollback
`npm install` can modify `package-lock.json`. If an unwanted lockfile change is committed, restore the previous version and reinstall:
git restore package-lock.json
npm install
`npm ci` itself is destructive to `node_modules` but ephemeral in CI; there is no installed state to roll back beyond restoring the lockfile.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.