Cache Dependencies in GitHub Actions
Stop re-downloading the same packages on every CI run by caching dependencies with keys that actually hit.
Key takeaways
Stop re-downloading the same packages on every CI run by caching dependencies with keys that actually hit.
On this page
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.
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.
- 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), notnode_modules. - pip:
~/.cache/pip. - Go:
~/.cache/go-buildand~/go/pkg/mod. - Gradle/Maven:
~/.gradle/caches,~/.m2/repository. - Cargo:
~/.cargo/registry,~/.cargo/git, andtarget/.
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:
- 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 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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon β CrashLoopBackOff, OOMKilled, Terraform state locks, and more β plus new guides as we publish them.
Argo CD & GitOps Best Practices in 2026: Deployments You Can Trust
A production-focused Argo CD and GitOps guide: declarative Applications and ApplicationSets, app-of-apps, sync waves and hooks, automated self-heal and prune, progressive delivery with Argo Rollouts, projects/RBAC, and secure secrets β with copy-paste examples.
GitHub Actions Recipes β The Practical Guide
Most GitHub Actions pain comes from the same handful of jobs done wrong. This is the map: the recipes that make pipelines fast, secure, and cheap.
More from DevOps
Explore more articles in this category
Kubernetes vs Docker Swarm in 2026: Is Swarm Still Worth It?
Swarm lost the orchestration war years ago, but it's still shipping and still simpler. Here is what that simplicity actually buys you, and what it costs.
Best Kubernetes IDE and GUI Tools in 2026
kubectl is fine until you're juggling five namespaces across three clusters. These are the tools that make that manageable, compared.
Chef vs Puppet vs Ansible: Configuration Management in 2026
One is agentless and Python-based, the other two run a persistent agent and a domain-specific language. The architecture difference matters more than the syntax.
You might have missed
Evergreen posts worth revisiting.