A workflow that runs npm ci on every push downloads the same few hundred packages from the registry each time, even when the lockfile hasn’t changed in a week. On a project with a real dependency tree that’s a minute or two gone before the first test starts, and you pay it again on every push and every pull request. actions/cache keeps those downloads between runs and hands them back when nothing relevant has changed.

How the cache action decides hit or miss

actions/cache stores each entry under a string you choose, the key, scoped to the branch that created it. A run can read caches from its own branch and from the default branch. On restore, the action compares your key against what’s stored:

  • An exact match to key is a hit. The files are downloaded and the job moves on.
  • No exact match falls through to restore-keys, checked in order, each one a prefix match against existing keys. The most recently created match wins.
  • No match at all and the step restores nothing. The job runs cold, and if it succeeds, a new entry is saved under key.

That’s why the standard pattern hashes a lockfile into the key:

1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

Change one line in package-lock.json and the hash changes, so the exact key misses on purpose. You want a cache that matches the new lockfile, not last week’s. The restore-keys prefix still matches, though, so the job starts from the previous cache instead of an empty directory, downloads only what’s new, and saves a fresh exact-match entry when it succeeds.

Two limits apply. A repository gets 10 GB of cache storage, shared by every workflow in it, and any entry that goes unused for 7 days is evicted. You don’t clean either up by hand: when the repo goes over, the oldest entries are dropped, and the next run that needed them pays the cold-install cost once.

Caching npm with setup-node’s cache option

For the common package managers you don’t write an actions/cache step at all. actions/setup-node, actions/setup-python and actions/setup-java each take a cache input that handles the key, the restore and the save for you:

1
2
3
4
5
6
7
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: 'npm'
- run: npm ci
- run: npm test

cache: 'npm' finds package-lock.json (or npm-shrinkwrap.json) at the repository root, hashes it, and caches npm’s download cache. cache: 'yarn' does the same with yarn.lock. In a monorepo where the lockfile lives at packages/api/package-lock.json, point at it explicitly:

1
2
3
4
5
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: 'npm'
    cache-dependency-path: packages/api/package-lock.json

setup-python works the same way: cache: 'pip' (or 'pipenv', 'poetry'), hashing requirements.txt by default. setup-java takes cache: 'maven' or cache: 'gradle'. Start here. Move to a manual actions/cache step only when you need a key or path the setup action won’t give you, or you’re caching something that has nothing to do with a language runtime.

Caching Maven and pip with actions/cache directly

A manual step always has the same three parts: the directory the tool writes its cache to, a key built from the file that pins dependency versions, and a prefix fallback.

1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.m2/repository
    key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      ${{ runner.os }}-m2-
1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

path is the tool’s own cache, not your project: pip’s download cache rather than your virtualenv, Maven’s local repository rather than target/. You can cache build output like target/ or dist/ instead, but it only pays off if the build is incremental. Otherwise you’re compressing and uploading files the next run throws away.

If you already cache Docker layers with cache-to: type=gha, as in Build and Push a Docker Image with GitHub Actions, those layers go into the same Actions cache service. They count against the same 10 GB and the same 7-day eviction as your dependency caches. A repository doing both hits the limit sooner than you’d expect, and large layer caches can push your npm cache out.

Cache keys in matrix builds

A matrix across Node versions or operating systems needs the matrix value in the key, or every leg competes for a single entry:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
strategy:
  matrix:
    node-version: [20, 22, 24]
steps:
  - uses: actions/cache@v6
    with:
      path: ~/.npm
      key: ${{ runner.os }}-npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-npm-${{ matrix.node-version }}-

Cache entries can’t be overwritten. Drop matrix.node-version from the key and all three legs try to save Linux-npm-<hash>: the first one to finish wins, and the others log a warning that the save failed. For ~/.npm that’s mostly harmless, since the downloaded tarballs don’t depend on the Node version. It stops being harmless when the cached path holds compiled output, like node_modules with a native addon. Then the Node 24 leg restores binaries built for Node 20, and you get failures that look like anything but a caching problem. runner.os in the key does the same job across OS legs.

What not to cache in GitHub Actions

Treat a cache entry like an image layer: anyone who can run a workflow that restores it can read it. Keep tokens and .env files out of any cached directory. The rule is the one from Environment Variables and Secrets in Docker: secrets come from secrets: at runtime, never from something written to disk and reused.

Two cases where caching costs more than it saves:

  • Anything that installs in a few seconds. A handful of pure-Python packages with no compiled extensions installs about as fast as it restores. The cache step has its own overhead for compressing and transferring the archive, and that can eat a saving this small.
  • A key too broad to ever hit. Hash the whole src/ directory instead of the lockfile and every code change invalidates the cache. Key on the file that pins dependency versions and nothing else.

How to tell whether the cache is hitting

The actions/cache step logs Cache restored from key: ... on a hit and Cache not found for input keys: ... on a miss. Read that line before you trust the setup.

To measure it, add cache: 'npm' or the manual step to one workflow, push twice, and compare how long the npm ci step takes on the cold run and the warm one. That difference is what the change is worth. If one job should build the cache and other jobs should only read it, replace actions/cache with actions/cache/restore@v6 and actions/cache/save@v6, which split the two halves so you decide where each one runs.