npm dependencies can be installed and cached in GitHub Actions, dramatically speeding up your build times.

Let’s see it in action. Imagine you have a Node.js project. Your package.json might look like this:

{
  "name": "my-node-app",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.18.2",
    "lodash": "^4.17.21"
  },
  "devDependencies": {
    "jest": "^29.5.0"
  }
}

And your GitHub Actions workflow (.github/workflows/ci.yml) might look like this:

name: CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js 18.x
      uses: actions/setup-node@v3
      with:
        node-version: '18.x'
        cache: 'npm' # This is the magic part!
    - run: npm ci
    - run: npm test

When this workflow runs, actions/setup-node will check if an npm cache exists for this workflow and Node.js version. If it does, it’ll restore that cache. Then, npm ci will install dependencies. If the cache was restored, npm ci will be incredibly fast because most dependencies are already present. If the cache didn’t exist or was invalid, it will install them and then create a new cache for future runs.

The problem this solves is the slow, repetitive downloading of npm packages on every single CI run. Without caching, each job starts with a blank slate, downloading gigabytes of dependencies every time, even if they haven’t changed. This can turn a quick build into a minutes-long ordeal, especially for large projects.

Internally, actions/setup-node with cache: 'npm' leverages GitHub Actions’ built-in caching mechanism. It identifies the npm cache directory (typically ~/.npm on Linux runners) and associates it with a unique key. This key is usually derived from the Node.js version and the operating system. When the action runs, it tries to download a cache artifact matching that key. If found, it restores the ~/.npm directory. If not, it proceeds with a fresh install and then uploads the ~/.npm directory as a cache artifact upon job completion. npm ci is preferred over npm install in CI because it’s faster and more reliable: it installs exact versions from package-lock.json and fails if there’s a discrepancy, ensuring reproducible builds.

The cache: 'npm' input to actions/setup-node is a convenience wrapper. Under the hood, it’s doing something similar to this:

    - name: Cache npm dependencies
      uses: actions/cache@v3
      id: npm-cache
      with:
        path: ~/.npm

        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

        restore-keys: |

          ${{ runner.os }}-node-

    - name: Install dependencies
      run: npm ci

Here, actions/cache is used directly. The key is crucial: it includes the operating system (runner.os) and a hash of your package-lock.json file (hashFiles('**/package-lock.json')). This means the cache is specific to your OS and your exact dependency tree. If package-lock.json changes, a new cache key is generated, and a new cache will be created. The restore-keys provide a fallback; if an exact match for the key isn’t found, it tries to find a cache matching the prefix, which is useful when only minor changes occur or when you’re on a different OS than the one the cache was created on.

The most surprising thing for many is how granular the cache key can be. While actions/setup-node simplifies it, using actions/cache directly allows you to tie the cache to specific files. For instance, if you also have a yarn.lock or pnpm-lock.yaml, you can include those in the hashFiles to create even more specific cache keys, ensuring that if your package manager or lockfile format changes, you get a fresh cache. This fine-grained control is what prevents cache corruption when your project evolves beyond just dependency updates.

The next hurdle you’ll likely encounter is managing multiple package managers or monorepo setups, where each package might have its own dependencies and lockfile.

Want structured learning?

Take the full Npm course →