Clean way to install NPM packages
One of my biggest frustrations in Node.js development is when your project works on your machine but breaks somewhere else. A common cause is package inconsistency between environments, even with a package.json file. Updating, removing, or installing packages can lead to a node_modules directory that doesn't perfectly match what's defined.
The standard fix — deleting node_modules and package-lock.json before running npm install again — is cumbersome. This is the exact problem npm ci (for clean install) was designed to solve.
This post will explain what npm ci is, how it differs from npm install, and how to use it effectively in your workflow.
What is npm ci? #
The npm ci command provides a clean, reliable, and fast way to install dependencies. It's designed for automated environments like continuous integration (CI), testing platforms, and deployments, where predictable and repeatable builds are essential.
Instead of resolving dependencies based on package.json, npm ci installs packages directly from the package-lock.json file. This guarantees that you get the exact same version of every package, every single time. To ensure a truly clean slate, it automatically deletes any existing node_modules directory before starting the installation.
While it's perfect for automation, npm ci is also incredibly useful during development. A common workflow is:
- Modify your
package.jsonwith new dependencies. - Run
npm installto update yourpackage-lock.json. - Commit both the
package.jsonandpackage-lock.jsonfiles. - Run
npm cito ensure your localnode_modulesis a perfect mirror of yourpackage-lock.json, just as it will be in production.
npm ci vs. npm install #
The key differences between npm ci and npm install are:
- Source of Truth:
npm ciuses onlypackage-lock.jsonornpm-shrinkwrap.jsonto install dependencies.npm installusespackage.jsonand updates the lock file accordingly. - Lock File Requirement:
npm cirequires apackage-lock.jsonto exist. If it's missing,npm ciwill exit with an error.npm installwill create one if it's not there. - Error Handling: If your
package.jsonandpackage-lock.jsonare out of sync,npm ciwill exit with an error instead of trying to update the lock file. - Immutability:
npm cinever writes topackage.jsonorpackage-lock.json. Your project files are never modified. - Clean Slate:
npm cialways deletesnode_modulesbefore installing to prevent any inconsistencies. - Package Management: You cannot use
npm cito add, update, or remove individual packages (e.g.,npm ci express). It only installs the entire project at once.
Note:
If you use special flags like --legacy-peer-deps when running npm install, you must use the same flags with npm ci. An easy way to enforce this is to create a .npmrc file in your project with these settings (e.g., legacy-peer-deps=true) and commit it to your repository.
Using npm ci with Overrides #
The overrides feature in package.json lets you enforce a specific version of a nested dependency, which is great for patching security vulnerabilities. However, npm ci introduces an important consideration.
Since npm ci relies exclusively on package-lock.json and never modifies it, the overrides must already be reflected in the lock file for npm ci to apply them. If the lock file is out of sync with the overrides in package.json, npm ci will fail.
To use overrides correctly with npm ci, follow these steps:
Define Overrides in package.json: Add or update the overrides field with the dependency versions you need.
{
"name": "my-project",
"version": "1.0.0",
"dependencies": {
"some-package": "^1.0.0"
},
"overrides": {
"a-nested-dependency": "1.2.3"
}
}
Update the Lock File: Run npm install. This command reads the overrides from package.json and updates package-lock.json to reflect the changes.
Commit the Lock File: Commit the updated package-lock.json to your version control system. It now serves as the single source of truth.
Run npm ci: Now, you and your CI/CD pipeline can run npm ci. It will read the updated lock file and install the exact overridden versions, ensuring a consistent and predictable installation.
The extra npm install step is necessary because it's the only command that can translate the intent from package.json into the concrete dependency tree stored in package-lock.json.