Using package-lock.json and npm ci for Deterministic Node.js Installs
Stop dependency drift in CI/CD by locking exact versions with package-lock.json and using npm ci to enforce reproducible installs across environments.
28 Jul 2025, 13:55 UTC

The Problem: Dependency Drift Undermines CI/CD Reliability
When developers run npm install, npm resolves version ranges in package.json and writes a package-lock.json that records the exact versions installed. If the lock file is not committed, or if a CI job runs npm install instead of npm ci, the resolver may pick a newer patch or minor version that appeared after the developer’s last install. This creates a mismatch between the local node_modules tree and the one built in the pipeline, leading to bugs that only appear in production.
Requirements for a Deterministic Install
To guarantee that every environment installs the same dependency tree, the workflow must satisfy three conditions:
- The exact version and integrity of every package must be recorded.
- The install command must never modify that record.
- The install must start from a clean
node_modulesfolder to avoid stale artifacts.
The Smallest Suitable Design
The design that meets the requirements with the fewest moving parts is:
- package-lock.json – a JSON manifest that lists each dependency with its resolved version, download URL, and SHA‑512 integrity hash.
- npm ci – the "clean install" command that reads the lock file, deletes
node_modules, and installs packages exactly as recorded, without ever updating the lock file.
No additional tooling or configuration is required beyond committing the lock file to version control.
Lock File as Source of Truth
The package-lock.json file is produced automatically the first time npm install runs. Its top‑level lockfileVersion field indicates the format version: 1 for npm 6‑7, 2 for npm 8‑9 (unless PnP is enabled). The file never contains version ranges; each entry points to a specific tarball and hash, making it immutable once committed.
npm ci vs npm install
Running npm install in CI is risky because it may:
- Update the lock file if a newer version satisfies the range in
package.json. - Leave existing
node_modulesin place, potentially mixing old and new packages. - It fails if
package-lock.jsonis missing. - It never writes to the lock file; a mismatch between
package.jsonand the lock results in an error. - It removes the current
node_modulesfolder before installing, guaranteeing a clean slate. - Committing
package-lock.jsonto the repository so every clone sees the same record. - Relying on the SHA‑512
integrityfield:npm cidownloads each tarball and aborts if the hash does not match, protecting against registry tampering or corrupted downloads.
In contrast, npm ci enforces the following:
Trust and Data Boundaries
The lock file serves as the single source of truth between a developer’s laptop and the build server. Trust is established by:
If the lock file is absent or altered, the boundary breaks and the build may diverge.
Operational Checks
To put the design into practice, add a step to your CI configuration that runs npm ci in the project root. The step requires read access to the repository and write access to the workspace (to create node_modules). No elevated privileges are needed.
# Example: GitHub Actions workflow
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
After the step completes, you can verify that the installed tree matches the lock file by checking a specific package:
# Run locally and in the CI environment
npm ls lodash
If the version printed differs between environments, the lock file was either not committed or npm install was used instead of npm ci.
Failure Modes
| Scenario | Result | Resolution |
|---|---|---|
| package.json edited without regenerating the lock | npm ci aborts with "package.json and package-lock.json are out of sync" | Run npm install locally to update the lock, commit both files, and retry. |
| Team members use different npm versions (e.g., npm 6 lock v1 vs npm 8 lock v2) | Lock file may be automatically upgraded on newer clients, causing spurious diffs | Standardize the Node.js/npm version via .nvmrc or a CI‑provided toolchain. |
| Monorepo with hoisted dependencies across workspaces | Sub‑project lock files may omit hoisted packages, leading to inconsistent trees | Enable npm workspaces and keep a single lock file at the repository root. |
Conditions That Would Change the Design
The lock‑file + npm ci pattern is the default for most Node.js projects, but you may need to reconsider if:
- You adopt npm 9+ Plug’n’Play (PnP) or an alternative package manager like pnpm, which removes the traditional
node_modulesfolder and uses a virtual store or a separate lock‑file format. - Build speed is critical and the
npm cicleanup step becomes a bottleneck; in that case, a global content‑addressable cache (e.g., pnpm’s store) can reduce redundant downloads while still preserving immutability.
Rollback (Only When State Changes)
If a lock‑file update introduces a regression, the operation that changed state is the commit that modified package-lock.json (and possibly package.json). To roll back:
- Revert the offending commit with
git revert <sha>or reset the branch to a previous known‑good commit. - Run
npm cilocally to restore thenode_modulesfolder to the exact state recorded in the restored lock file. - Push the revert; the CI pipeline will automatically use the restored lock file on its next run.
Because npm ci never writes to the lock file, the rollback does not require any additional cleanup beyond restoring the files and re‑running the install.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.