Every Node.js project gives you two ways to install dependencies: npm install and npm ci. On your laptop they feel interchangeable — both leave you with a working node_modules. But in a build pipeline, the difference is the line between a deploy that's reproducible and one that quietly ships different code than you tested.
This guide explains what separates the two commands, why npm ci is the right default for CI/CD and production builds, and how the lockfile underneath them makes reproducible deployments possible. If you're still choosing a package manager, start with our comparison of npm vs Yarn vs pnpm vs Bun.
The Short Answer
Use npm install |
Use npm ci |
|
|---|---|---|
| Where | Local development | CI/CD pipelines, production builds |
| Lockfile | May update it | Reads it strictly, never writes |
| node_modules | Updates in place | Deletes and reinstalls clean |
| If lockfile is out of sync | Silently updates it | Fails the build |
| Speed | Slower | Faster |
If you take nothing else away: use npm install while you're developing and adding packages; use npm ci everywhere a machine builds your code.
npm install vs npm ci: The Core Difference
Both commands read package-lock.json. The difference is what they're allowed to do with it.
npm install treats the lockfile as a starting point. It resolves the version ranges in your package.json, and if a newer version now satisfies a range, it installs that version and rewrites the lockfile to match. That's exactly what you want while developing — you're adding and updating packages, and the lockfile should follow.
npm ci (short for clean install
) treats the lockfile as law. It:
- Deletes
node_modulesentirely before installing, so no stale state from a previous build leaks in. - Installs the exact versions recorded in
package-lock.json— no resolution, no negotiation. - Never modifies the lockfile.
- Fails immediately if
package-lock.jsonandpackage.jsonare out of sync.
That last point is the one that saves you. If a teammate bumped a dependency in package.json but forgot to commit the regenerated lockfile, npm install would paper over it by silently resolving new versions. npm ci stops the build and tells you — before an untested dependency tree reaches production.
# In your build pipeline
npm ci
npm run build
Why npm ci Is the Right Default for CI/CD
Three properties make npm ci the correct choice anywhere a machine builds your code:
- Reproducibility. Every build — a teammate's, the CI server's, the production deploy — installs the identical dependency tree. What passed tests is what ships. This is the core of continuous integration: a build is only trustworthy if it's deterministic.
- Speed. Because
npm ciskips dependency resolution and installs straight from the lockfile, it's consistently faster thannpm installon a clean machine — which is every CI run. - Safety. The clean
node_moduleswipe plus the strict lockfile check means a build can't succeed on a lie. No stale packages, no drift, noworks in CI, breaks in prod.
If your pipeline still runs npm install, switching that single line to npm ci is one of the cheapest reliability upgrades you can make to a CI/CD pipeline. Want it running against your own repository? Start a DeployHQ trial and point a build pipeline at your repo — npm ci on every push, in a clean environment.
The Lockfile: What Makes npm ci Work
npm ci is only as good as the lockfile it reads. So it's worth understanding what package-lock.json actually is — and why committing it is non-negotiable.
package-lock.json is an automatically generated file that records the exact version of every package installed in your project, including every nested dependency. npm creates and updates it whenever you run npm install. Where package.json declares what your project needs, package-lock.json records what was actually installed.
package.json vs package-lock.json
| Aspect | package.json | package-lock.json |
|---|---|---|
| Purpose | Declares dependencies and version ranges | Records exact installed versions |
| Version format | Semver ranges (^1.2.3, ~1.2.3) |
Exact versions (1.2.3) |
| Managed by | Developer | npm (auto-generated) |
| Includes nested deps | No | Yes — the entire dependency tree |
| Should you edit it | Yes | No — let npm manage it |
Consider this package.json entry:
{
"dependencies": {
"express": "^4.18.0"
}
}
The ^4.18.0 range means any version from 4.18.0 up to (but not including) 5.0.0.
Today that might resolve to 4.18.2; next month it could resolve to 4.21.0. Without a lockfile, two developers running npm install at different times get different versions. The lockfile eliminates that ambiguity — it pins every package to an exact version, a download URL, and an integrity hash.
The Works on My Machine
Problem
This is the exact scenario the lockfile — enforced by npm ci — prevents:
- Developer A runs
npm installon Monday — Express4.18.2is installed - Developer B runs
npm installon Friday — Express4.21.0shipped on Wednesday - Developer B hits a subtle bug Developer A can't reproduce
- The CI server installs yet another combination
With package-lock.json committed and npm ci in the pipeline, all three environments get the same dependency tree, every time.
Always Commit Your Lockfile
Yes, always. Committing package-lock.json gives you:
- Reproducible builds — every developer, CI server, and deploy target gets identical dependencies
- Security auditing — lockfile diffs in PRs make unexpected packages visible
- Working
npm ci— it requires a committed lockfile; without one it errors out - Supply-chain protection — integrity hashes catch tampered or corrupted packages
A common .gitignore mistake is excluding package-lock.json. If it's in your .gitignore, remove it now.
The Other Package Managers Have Their Own ci
Command
npm ci isn't unique — every modern package manager has a strict, lockfile-frozen install mode meant for CI. If your project uses one of these instead, reach for its equivalent in your pipeline:
| Package manager | Local install | Strict CI install |
|---|---|---|
| npm | npm install |
npm ci |
| Yarn Classic (v1) | yarn install |
yarn install --frozen-lockfile |
| Yarn Berry (v2+) | yarn install |
yarn install --immutable |
| pnpm | pnpm install |
pnpm install --frozen-lockfile |
| Bun | bun install |
bun install --frozen-lockfile |
They all do the same job: install exactly what the lockfile says and fail if it's out of sync, rather than silently updating it. The principle is identical no matter which tool you standardize on.
Using npm ci in Your Deployment Pipeline
When code moves from development to production, npm ci is what guarantees the server installs what you tested. A typical DeployHQ build pipeline runs:
npm ci
npm run build
npm ci reads the committed lockfile and installs the exact dependency tree in a clean environment, which means:
- Staging and production builds are identical
- A passing CI build guarantees the same versions ship
- No untested dependency can sneak into production
If you deploy from GitHub or GitLab, DeployHQ pulls your repository — including the lockfile — and runs your build commands for you. For a full walkthrough of configuring Node versions and caching, see using Node.js and npm with the DeployHQ build pipeline. And if your build pulls packages from a private registry or repo, the credential patterns in private repository dependencies keep npm ci working without leaking secrets.
For agencies managing many client projects, this determinism isn't a nicety — every deploy must produce the same output regardless of when it runs.
Common Pitfalls
- Running
npm installin CI. It may rewrite the lockfile mid-build, so production can differ from what you tested. Usenpm ci. - A lockfile that's out of sync. If
npm cifails with a sync error, that's the feature working. Runnpm installlocally, verify everything works, and commit the regenerated lockfile — don't switch the pipeline tonpm installto dodge the error. - Editing
package-lock.jsonby hand. Never. To resolve a merge conflict, accept either side and regenerate:
git checkout --theirs package-lock.json
npm install # regenerates from both sets of package.json changes
git add package-lock.json
git commit -m "Resolve lockfile merge conflict"
- Committing more than one lockfile. If a repo has both
package-lock.jsonandyarn.lock, pick one package manager and delete the other lockfile — competing lockfiles produce inconsistent installs.
FAQ
Is npm ci faster than npm install?
Yes, on a clean machine. npm ci skips dependency resolution and installs straight from the lockfile, which is why it's the better fit for CI runs that always start fresh.
Can I use npm install instead of npm ci in production?
You can, but you shouldn't. npm install may update the lockfile, so production could differ from what you tested. Use npm ci for all automated environments.
Does npm ci update package.json or package-lock.json?
Never. npm ci only reads them. If they're out of sync, it fails rather than writing changes — which is exactly what you want in a pipeline.
Why does npm ci delete node_modules? To guarantee a clean install with no leftover state from a previous build. It's slower to wipe and reinstall than to patch in place, but on CI that clean slate is the whole point.
Why is my lockfile changing when I haven't touched package.json?
Usually because teammates use different npm versions. Standardize with a .nvmrc or an engines field in package.json.
How do I know if my lockfile is out of date?
Run npm ci. If it fails with a sync error, regenerate with npm install, verify everything works, and commit.
npm install and npm ci aren't competitors — they're tools for two different jobs. Install and update packages locally with npm install; let npm ci enforce that exact, tested dependency tree everywhere a machine builds your code. Commit your lockfile, wire npm ci into your pipeline, and works on my machine
stops being a deployment risk.
Deploy Node.js apps with reproducible builds — DeployHQ runs npm ci in a clean environment on every push. See pricing for team plans.
Questions? Reach out at support@deployhq.com or on X (@deployhq).