A reliable CI pipeline should install the same dependency versions that developers use on their local machines. However, this consistency can break when GitHub Actions installs dependencies without respecting the project's lockfile or when dependency caches are restored using incomplete cache keys.
For example, a developer may run tests successfully because their local environment uses the exact package versions recorded in package-lock.json. A GitHub Actions workflow that uses a general dependency installation command may resolve newer compatible versions instead. The pipeline can then fail even though the developer has not changed the application code.
This problem is not limited to Node.js. Projects using npm, Yarn, pnpm, Python, .NET, and other package managers need a reliable way to keep dependency resolution consistent across local development and automated builds.
Lockfiles provide that foundation, but simply committing a lockfile is not enough. The workflow must use the correct installation command, keep the lockfile synchronized with dependency manifests, and configure caching without allowing stale or incompatible dependencies to affect builds.
Why Dependency Versions Can Differ Between Local and CI Environments
Most package managers allow developers to declare dependency version ranges rather than exact versions.
Consider the following package.json file:
{
"name": "customer-portal",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.0",
"axios": "^1.6.0"
}
}The caret character allows compatible updates within the specified major version range.
For example, a dependency declared as ^4.18.0 can resolve to a later compatible 4.x release, subject to the package manager's version-resolution rules.
This is convenient during development because developers can receive compatible dependency updates without changing package.json every time.
However, it introduces a problem when different environments resolve dependencies at different times.
Imagine this scenario:
A developer installs dependencies on Monday.
The package manager resolves the available compatible versions.
The developer runs tests successfully.
A new compatible dependency version is published on Wednesday.
GitHub Actions runs a fresh installation on Thursday.
The CI environment resolves the newer version.
A test fails because the new dependency behaves differently.
The application code has not changed, but the dependency tree has.
This is one reason reproducible builds matter. A build should use a known dependency tree rather than independently resolving package versions every time it runs.
Understand What a Lockfile Actually Does
A lockfile records the resolved dependency tree used by a package manager.
For an npm project, the file is package-lock.json.
For Yarn, it is yarn.lock.
For pnpm, it is pnpm-lock.yaml.
Consider a simplified package-lock.json:
{
"name": "customer-portal",
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": {
"express": "^4.18.0"
}
},
"node_modules/express": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
"integrity": "sha512-example"
}
}
}The package manifest describes the dependency requirement, while the lockfile records the resolved package version and associated installation information.
The lockfile also contains information about transitive dependencies.
For example, your application may directly depend on Express, but Express itself depends on other packages. Those packages can have their own dependencies.
A lockfile helps preserve the complete resolved dependency tree instead of allowing every environment to independently choose versions.
Why should lockfiles be committed to Git?
Committing lockfiles provides several benefits:
Developers and CI environments use consistent dependency resolutions.
Dependency changes become visible in pull request diffs.
Builds become more predictable.
Unexpected dependency upgrades are easier to identify.
Rollbacks can restore a previously committed dependency tree.
Production deployments can use the same resolved dependencies as tested builds.
A lockfile should normally be committed alongside the corresponding dependency manifest.
For example:
customer-portal/
│
├── package.json
├── package-lock.json
├── src/
├── tests/
└── .github/
└── workflows/
└── build.ymlDo not commit the node_modules directory simply to preserve dependency versions. The lockfile is the appropriate source-controlled representation of the dependency tree.
Use npm ci Instead of npm install in GitHub Actions
One of the most important changes you can make to an npm-based GitHub Actions workflow is replacing npm install with npm ci.
Both commands install dependencies, but they have different purposes.
npm install
The npm install command is commonly used during development.
npm installIt can install dependencies and update package-lock.json when the dependency manifest and lockfile need to be reconciled.
This is useful when adding packages, updating versions, or changing dependency requirements.
However, it is not the preferred command for a CI installation that should preserve an existing lockfile.
npm ci
The npm ci command is designed for clean and automated installations.
npm ciIts important characteristics include:
Requires an existing package-lock.json or npm-shrinkwrap.json.
Fails if package.json and the lockfile describe incompatible dependency requirements.
Removes an existing node_modules directory before installation.
Does not modify package.json or package-lock.json.
Installs using the resolved dependency information in the lockfile.
This makes npm ci a better choice for repeatable CI builds.
A problematic GitHub Actions workflow
Consider this workflow:
name: Node.js CI
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Install dependencies
run: npm install
- name: Run tests
run: npm testThe workflow uses npm install, which may reconcile the lockfile rather than treating it as an immutable installation contract.
A better GitHub Actions workflow
Replace npm install with npm ci:
name: Node.js CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build application
run: npm run buildNow the workflow installs dependencies using the committed lockfile.
If package.json and package-lock.json are inconsistent, the installation fails instead of silently updating the lockfile.
That failure is useful. It tells the team that the dependency changes need to be resolved and committed before the pipeline can pass.
Configure GitHub Actions Caching Without Breaking Lockfile Consistency
Dependency installation can take considerable time, especially when an application has many direct and transitive dependencies.
GitHub Actions supports dependency caching to reduce repeated downloads.
For Node.js applications, actions/setup-node provides built-in caching for npm, Yarn, and pnpm.
Consider this configuration:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npmThe setup-node action identifies the package manager's dependency file and uses its hash when creating the cache key.
For npm, this is generally package-lock.json.
The important distinction is that dependency caching and dependency installation are separate operations.
Caching restores package-manager data to make installation faster. The package manager still performs the installation and validates the dependency tree.
The setup-node action does not cache the node_modules directory. It caches package-manager data that can be reused across installations.
This means the recommended combination remains:
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ciYou get the performance benefit of caching while keeping the installation controlled by the lockfile.
Why should the cache key depend on the lockfile?
Suppose the application changes a dependency from one version to another.
The package-lock.json file changes accordingly.
Because setup-node uses the lockfile hash in its cache key, a different dependency definition produces a different cache key.
This prevents the workflow from treating a cache created for an older lockfile as an exact match for the new dependency state.
The lockfile remains the source of truth. The cache is only an optimization.
Avoid manually caching node_modules without a clear reason
A common custom caching approach looks like this:
- name: Cache node_modules
uses: actions/cache@v5
with:
path: node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}This is generally unnecessary when using setup-node's built-in caching.
Caching node_modules can introduce additional complexity involving operating system differences, Node.js versions, native dependencies, and installation lifecycle scripts.
Prefer caching the package manager's download data and running npm ci for every job.
This keeps the installation process predictable.
Keep Yarn and pnpm Lockfiles Consistent
The same principle applies when your project uses Yarn or pnpm.
Each package manager provides an installation mode that prevents CI from modifying the lockfile.
Yarn projects
Yarn Berry provides the immutable installation option:
yarn install --immutableThis command fails if the installation would require modifying the lockfile.
For older Yarn Classic projects, use:
yarn install --frozen-lockfileA GitHub Actions workflow for Yarn can look like this:
name: Yarn CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run tests
run: yarn test
- name: Build application
run: yarn buildUse the command appropriate for the Yarn version configured in the repository.
pnpm projects
pnpm supports frozen lockfile installation:
pnpm install --frozen-lockfile
Join the conversation! Your thoughts help the community grow.