Copy a Dockerfile or GitHub Actions workflow from a tutorial and you’ll almost always see npm ci instead of the npm install you run locally every day. They look like they should be interchangeable — both install your dependencies — but they operate on package-lock.json and node_modules in fundamentally different ways, and using the wrong one in the wrong place is a common source of “works on my machine, fails in CI” bugs.
Quick answer
npm install reads package.json, resolves versions, and updates package-lock.json if needed — it’s built for adding, updating, and actively managing dependencies. npm ci does the opposite: it reads package-lock.json only, refuses to run if it doesn’t exactly match package.json, deletes node_modules first, and never modifies the lockfile. It’s built for reproducible, unattended installs — exactly what a CI pipeline needs.
Which Command Should You Use?
Answer one question
What Each Command Actually Does
npm install
- Reads
package.jsonto determine what’s needed. - Resolves the newest versions allowed by your version ranges (
^,~, etc.) that are also compatible with what’s already inpackage-lock.json. - Writes any changes back to
package-lock.jsonandpackage.jsonif you’re adding/removing a package. - Reuses your existing
node_moduleswhere possible instead of wiping it — generally faster for small, incremental changes.
npm install
npm install lodash # adds a new dependency
npm install lodash@4.17.21 # installs a specific version
npm ci
- Requires a
package-lock.json(ornpm-shrinkwrap.json) to already exist — it errors out immediately if one is missing. - Deletes the existing
node_modulesfolder entirely before installing anything. - Installs the exact versions listed in the lockfile — no resolution, no range-matching, no “close enough.”
- Fails immediately if
package.jsonandpackage-lock.jsonare out of sync, rather than silently reconciling them. - Never writes to
package.jsonorpackage-lock.jsonunder any circumstance.
npm ci
Side-by-Side Comparison
| Behavior | npm install | npm ci |
|---|---|---|
| Requires existing lockfile | No | Yes — errors without one |
| Can modify package.json | Yes (when adding/removing packages) | Never |
| Can modify package-lock.json | Yes | Never |
| Deletes node_modules first | No | Yes, always |
| Behavior on package.json/lockfile mismatch | Reconciles and updates the lockfile | Fails immediately with an error |
| Install speed | Slower for full/clean installs | Faster — skips resolution logic entirely |
| Best used for | Local development, adding/updating packages | CI/CD pipelines, Docker builds, deployment |
Why CI Pipelines Specifically Need npm ci
A CI pipeline’s whole job is to reproduce the exact same install every single time, regardless of which machine runs it or when. npm install is deliberately a little flexible — it can silently pick a newer patch version than what’s pinned if your ranges allow it, which is exactly the kind of small drift you don’t want in an automated pipeline. A build that passed yesterday could behave differently today for no code reason at all.
npm ci removes that flexibility on purpose. Because it installs precisely what’s locked and refuses to proceed on any mismatch, a failing npm ci step in your pipeline is actually useful signal — it means someone changed package.json without regenerating the lockfile, not that npm silently “fixed” the discrepancy for them.
The most common npm ci failure
npm error npm ci can only install packages when your package.json and package-lock.json are in sync — this means someone edited package.json directly (or merged a branch that did) without running npm install locally afterward to regenerate the lockfile. Fix it by running npm install locally, committing the updated package-lock.json, then re-running npm ci.
package-lock.json Best Practices
- Always commit it. It’s not a build artifact — it’s what makes your install reproducible across machines and over time.
- Never edit it by hand. Let
npm installregenerate it after you changepackage.json. - Don’t
.gitignoreit “to avoid merge conflicts” — this trades a solvable annoyance for genuinely nondeterministic builds. - Resolve lockfile merge conflicts by regenerating, not hand-editing: pull the latest
package.json, delete the conflicted lockfile, runnpm installagain, and commit the clean result. - Run
npm cilocally before pushing if you want to catch sync issues before your pipeline does.
Troubleshooting Checklist
- Getting a lockfile sync error in CI? Run
npm installlocally, confirmpackage-lock.jsonchanged, and commit it. - CI install unexpectedly slow? Confirm you’re actually using
npm ciand notnpm installin your workflow file — this is a very common copy-paste mistake. - Getting different behavior locally vs. in CI with the “same” dependencies? Delete your local
node_modulesand runnpm cilocally to match CI’s exact install. - Docker build cache not helping? Make sure you’re copying
package.jsonandpackage-lock.jsoninto the image and runningnpm cibefore copying the rest of your source code, so the dependency layer only rebuilds when those two files change. - No
package-lock.jsonin the repo at all? You’ll neednpm installfirst to generate one —npm cican’t bootstrap a project from nothing.
FAQ
Is npm ci always faster than npm install?
For a full, clean install, generally yes — it skips dependency resolution entirely and installs exactly what’s pinned. For small incremental changes where most of node_modules can be reused, npm install can sometimes be comparable or faster.
Can I add a new package using npm ci?
No. npm ci only installs what’s already in the lockfile and will not add anything new — use npm install to add a dependency.
Does npm ci work without an internet connection?
No, by default it still fetches packages from the registry (or your configured cache) unless you’re running in an environment with npm’s offline cache fully populated. It skips resolution logic, not network access.
Should I use npm ci in my Dockerfile?
Yes, in almost all cases — it guarantees the image gets the exact locked dependency versions and fails loudly if the lockfile is out of date, which is exactly the behavior you want in a reproducible build.
What happens if I run npm ci with no node_modules folder yet?
It works fine — there’s simply nothing to delete first, and it installs directly from the lockfile.

