# Cache Dependencies in GitHub Actions

URL: https://www.devopsness.com/blog/github-actions-cache-dependencies
Author: Kiril Urbonas
Published: 2026-07-23
Updated: 2026-07-23
Categories: DevOps
Tags: CI/CD, GitHub Actions, DevOps, Caching, Automation

Summary: Stop re-downloading the same packages on every CI run by caching dependencies with keys that actually hit.

---
# Cache Dependencies in GitHub Actions

Every GitHub Actions runner starts as a clean machine. That is great for reproducibility and terrible for speed, because a fresh runner has none of your dependencies. Without caching, every push re-downloads the entire `npm`, `pip`, or `go` dependency tree from the network. On a real project that is often 30 to 90 seconds of pure download time, repeated across every job in every workflow, on every commit. Caching is the single highest-return optimization you can make to a CI pipeline, and it takes about three lines to turn on.

## Why caching matters

A cache stores a directory from one run and restores it at the start of the next. Instead of hitting the npm registry or PyPI over the network, the package manager finds its download directory already populated and skips straight to install. The registry is not down, your firewall is not throttling you, and your build minutes are not being spent on bytes you already had yesterday.

The mental model is simple: **save** a directory keyed by something, and on the next run **restore** whatever was saved under the same key. Everything interesting is in choosing that key well.

## The easy path: built-in caching in setup-*

Before reaching for anything general, check whether your `setup-*` action already does it. `setup-node`, `setup-python`, `setup-java`, and `setup-go` all ship a `cache:` input that wires up the right directories and a sensible key automatically. This is the option you should use unless you have a reason not to.

```yaml
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: npm
  - run: npm ci
```

That `cache: npm` handles the whole thing. It caches npm's download cache (not `node_modules`), keys it on your `package-lock.json`, and restores it before `npm ci` runs. Switch the value to `pip`, `yarn`, `pnpm`, `gradle`, or `maven` for the other ecosystems. If your lockfile lives outside the repo root, point it there with `cache-dependency-path`.

For most repositories, that is the entire task. Do not build a custom cache block when the built-in one already covers your package manager.

## The general tool: actions/cache@v4

When there is no built-in option, or you are caching something unusual (a compiler toolchain, a build output directory, a `~/.cargo` tree), reach for `actions/cache@v4` directly. You give it a `path` to store and a `key` to store it under.

```yaml
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-
```

Here is what each piece does. The `key` is an exact-match lookup. If a cache with that key exists, it is restored and nothing more happens. If not, the job runs, and at the end the directory is saved under that key for next time.

## Designing a good key

The key is the whole game. A good key changes exactly when your dependencies change and stays stable otherwise. The standard recipe is a hash of your lockfile:

```
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}
```

`hashFiles()` produces a fingerprint of the file contents. Edit a dependency version, the lockfile changes, the hash changes, and you get a fresh cache. Leave dependencies alone, the hash is identical, and you get an exact hit. Include `runner.os` because a Linux cache is useless on a Windows runner. Hash the **lockfile**, not the manifest: `package-lock.json` over `package.json`, `poetry.lock` over `pyproject.toml`, because the lockfile is what actually pins versions.

`restore-keys` is your safety net for the miss. When the exact key does not match (you bumped one package), GitHub walks the `restore-keys` list as prefixes and restores the most recent cache that starts with one of them. You get last run's cache, which has 95% of your dependencies, and the package manager only downloads the one thing that changed. A prefix that ends with a dash, like `pip-${{ runner.os }}-`, is the common pattern.

A cache miss on a lockfile change is expected and correct. It is the system doing its job: new dependencies, new cache. If you are missing on *every* run with an unchanged lockfile, your key is non-deterministic (often a timestamp or commit SHA snuck in) and needs fixing.

## What to cache, and what not to

Cache the package manager's download or global store, not the installed project directory:

- **npm/yarn/pnpm:** the manager's cache dir (`~/.npm`, pnpm store), not `node_modules`.
- **pip:** `~/.cache/pip`.
- **Go:** `~/.cache/go-build` and `~/go/pkg/mod`.
- **Gradle/Maven:** `~/.gradle/caches`, `~/.m2/repository`.
- **Cargo:** `~/.cargo/registry`, `~/.cargo/git`, and `target/`.

Avoid caching `node_modules` directly. It is platform-specific, holds compiled native modules, and restoring a stale copy skips the integrity checks `npm ci` performs. Caching the download directory keeps installs both fast and correct.

A Go example, since it has two directories worth caching:

```yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
    key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
    restore-keys: |
      go-${{ runner.os }}-
```

## Scope, isolation, and limits

Caches are scoped by key and by branch, and that scoping has security teeth. A job can read caches created on its own branch and on the default branch, but it cannot read caches from *other* branches. That isolation exists so a pull request cannot poison another branch's cache. In practice it means feature branches warm from `main` on their first run, then build their own cache as they go.

The hard limit is 10 GB of cache per repository. Cross it and GitHub evicts entries, least-recently-used first. Caches also expire after 7 days without a hit. So a giant, rarely-used cache will just get pushed out. Keep keys tight, cache download directories rather than sprawling build trees, and you will rarely approach the ceiling.

## Docker layer caching is a different problem

If you are building container images, none of the above applies to your image layers. Docker layer caching runs through BuildKit and `cache-from`/`cache-to`, or the `gha` cache backend, not `actions/cache`. It is a separate mechanism with its own tradeoffs, covered in our [how to speed up a slow CI pipeline](/blog/speed-up-ci-pipeline) guidance and the Docker build post. Do not try to cache `/var/lib/docker` with `actions/cache`; it does not work the way you would hope.

## The call we'd make

Start with the built-in `cache:` input on your `setup-*` action. It is one line, it picks the right directory and key, and it is correct out of the box. Only drop to `actions/cache@v4` when you are caching something the setup actions do not cover, and when you do, key on the lockfile hash and always add a prefix `restore-keys` so a dependency bump costs one download instead of the whole tree. Cache download directories, never `node_modules`. That combination gets you most of the available speedup with almost no maintenance burden. For the wider workflow patterns this fits into, see our [GitHub Actions recipes](/blog/github-actions-recipes).

