npm ci in CI, npm install on Your Machine: A Lockfile Discipline Task Guide
A task guide to running npm ci in CI with a committed package-lock.json: prerequisites, an illustrative workflow, drift checks, and recovery from sync errors and cache corruption.
20 Oct 2025, 04:45 UTC

Every pipeline that runs npm install re-resolves your dependency tree. Ranges like ^1.4.2 float to newer minors, transitive dependencies shift, and the lockfile can drift from what your teammates have on disk. The practical decision this guide implements: run npm install on your machine when you intentionally change dependencies, and run npm ci in CI/CD and production builds so every environment installs exactly the tree recorded in a committed package-lock.json.
What you are setting up
The end state:
- CI installs dependencies strictly from the committed lockfile — no fresh resolution, no silent lockfile rewrites.
- A manifest/lockfile mismatch fails the build loudly instead of being quietly repaired.
- Repeat builds are faster because the npm cache is preserved between runs, keyed on the lockfile.
npm ci has existed since npm 5.7.0 (2018), so any maintained npm has it. The differences between releases are in flags, peer-dependency resolution, and lockfile format — which is why pinning the npm version, covered below, matters.
Prerequisites
- Node.js and npm available on the runner, with the version pinned via your CI image or a version-setup action, because behavior differs across npm majors.
- A
package-lock.jsonat the project root, committed to version control.npm ciexits with an error if none exists; it never creates one. - An npm version compatible with the lockfile's
lockfileVersion. Mixed npm versions across a team are a common source of noisy lockfile diffs. - For private or scoped packages, registry credentials configured before the install step runs, or the fetch fails mid-install.
npm install vs npm ci at a glance
| npm install | npm ci | |
|---|---|---|
| Where it belongs | Dev machine, when changing dependencies | CI/CD and production builds |
| Resolution | Resolves from package.json and may rewrite the lockfile | Installs strictly from package-lock.json |
| node_modules | Updated in place | Deleted, then reinstalled |
| Manifest/lockfile mismatch | Silently re-resolved | Exits with an error |
| Writes package.json | Yes, with save flags | Never |
Procedure
1. Commit the lockfile, ignore node_modules
Check that .gitignore lists node_modules/ and does not list package-lock.json. For an application, the lockfile belongs in version control. Note that npm-shrinkwrap.json is a different tool: it locks the tree for consumers of a published package. Pick one deliberately rather than committing both.
2. Replace npm install with npm ci in the pipeline
In your CI configuration, swap the install step. The following GitHub Actions workflow is illustrative — verify the current major versions of the actions and their supported inputs in the official docs before adopting it:
# .github/workflows/ci.yml — illustrative; check current action majors
name: ci
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
Notes on this configuration:
node-version: 20is the placeholder to adjust; it also determines the bundled npm version.cache: npmcaches the npm cache directory (notnode_modules) and keys it on the lockfile, which is the safe default.- Run tests and build after
npm ci, and publish the artifact from that same job so it matches the installed tree.
3. Configure private registries before the install step
# project .npmrc
@myorg:registry=https://registry.example.com/
//registry.example.com/:_authToken=${NPM_TOKEN}
Set NPM_TOKEN as a secret in your CI system and expose it to the install step. npm expands environment variables in .npmrc, so the token never needs to be committed.
4. Omit dev dependencies only for production artifacts
For images or artifacts that must not carry dev tooling, npm ci --omit=dev skips devDependencies on npm 8 and later (older releases used --production). Run plain npm ci for jobs that test or lint, since those need devDependencies. Confirm the flag exists in your pinned npm with npm ci --help.
5. Keep lockfile regeneration a reviewed, local step
Adding or upgrading a dependency is a deliberate act: run npm install locally, review the package-lock.json diff in the pull request, and commit it. Large unrelated churn in that diff usually means someone used a different npm major version — fix the version pin rather than rubber-stamping the diff.
Checks that confirm it works
- Reproducibility. In a clean checkout, run
npm ci, capture the tree withnpm ls --all, runnpm ciagain, and compare the two outputs — they should be identical. - Drift fails loudly. On a scratch branch, add a dependency line to
package.jsonwithout runningnpm install, then runnpm ci. It should exit nonzero with an out-of-sync error rather than resolving the new package. Revert the scratch change afterwards. - The build does not touch the lockfile. After a pipeline run,
git status --porcelain package-lock.jsonon a fresh clone should show nothing. - Speed is real for your tree. Time both commands on the same clean project (
time npm civersustime npm install). npm ci typically wins because it skips dependency resolution, but the margin depends on your dependency set — measure rather than assume.
Recovery
Out-of-sync error in CI
Do not repair the lockfile in the pipeline. Run npm install locally, inspect git diff package-lock.json, confirm the changes match the intended dependency change, and commit. The next CI run should pass. If the diff is large and unrelated, align the team's npm version first.
Suspected cache corruption
Run npm cache verify — locally or as a temporary runner step — and rerun the job. npm's cache is content-addressable and self-validating, so verify resolves most issues; npm cache clean --force is the heavier fallback. A corrupted node_modules is not a separate problem here: npm ci deletes it before installing.
Something built inside node_modules disappeared
Because npm ci removes node_modules first, anything generated there — patched files, native builds — is lost on every run. Apply patches in a postinstall step or rebuild native code as part of the pipeline, after the install.
Limitations to keep in mind
- Peer-dependency resolution, lockfile format defaults, and flag support vary across npm majors; pin npm in CI and keep the team on the same major.
- Caching
node_modulesinstead of the npm cache invites stale or platform-mismatched installs, especially with native modules. - In a workspace (monorepo) setup on npm 7 or later, a root-level
npm ciinstalls all workspaces' dependencies from the single lockfile. - Published libraries that must lock their tree use
npm-shrinkwrap.json, which ships in the tarball — a separate decision from committingpackage-lock.jsonfor an application.
The habit to internalize: npm install is an authoring command; npm ci is a consumption command. Once CI only consumes, dependency changes become reviewable diffs instead of ambient drift.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.