Speed up GitHub Actions with dependency caching
Cache node_modules, pip wheels, and Go modules keyed on your lockfile hash, and warm the cache on main to cut minutes off every GitHub Actions run.
Two decisions determine whether a dependency cache in GitHub Actions helps or hurts: what the key is made of, and which runs are allowed to write it. Get the first wrong and every push downloads the internet again; get the second wrong and a pull request can plant a node_modules that your release build later restores. The rules for both are precise, and the second set changed recently enough that most existing workflows have not caught up.
The key is the lockfile, not the commit
A cache should be reused when dependencies are unchanged and rebuilt when they change, so its key hashes the lockfile (package-lock.json, poetry.lock, go.sum) and nothing more volatile. restore-keys lists prefixes to fall back to when the exact key misses, so a lockfile change still starts from the previous tree and only downloads the difference. The search order is exact key, then each restore key, first in the current branch and then in the default branch.
- uses: actions/cache@v6with:path: ~/.npmkey: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}restore-keys: |npm-${{ runner.os }}-# or, for the common package managers, let the setup action do the same:- uses: actions/setup-node@v7with:node-version: 24cache: npm
Cache the package manager's download directory (~/.npm, pip's wheel cache, ~/go/pkg/mod), not node_modules or a virtualenv. The install step still runs, so postinstall scripts and platform-specific binaries are produced on the runner that uses them, and a restored cache can only make the install faster, never different. The setup-* actions for Node, Python, Go, Java, Ruby and .NET implement exactly this when given a cache: input, with the key derived from the lockfile they find.
Cache restored from key: npm-Linux-a1b2c3…npm ci ... 11.8sexact hit: same lockfile, same OSCache not found for input keys: npm-Linux-9f8e7d…Cache restored from key: npm-Linux-a1b2c3… (restore-keys prefix match)npm ci ... 34.2slockfile changed: the old tree was restored, only new packages were fetched, and the new exact key is saved after the jobWho can read a cache, and who can write one
Caches are scoped by branch. A run can restore caches created on its own branch or on the default branch, and a pull request run can additionally read caches from its base branch. Writing is the sensitive direction. Only runs triggered by push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build and schedule may create or overwrite caches in the default branch's scope; runs from triggers an outsider can influence, such as pull_request_target, issue_comment and workflow_run, get read-only access there by default. A pull_request run writes only to its own merge-ref scope and cannot reach the default branch's caches at all. The cache-mode key makes the access explicit per workflow or job:
cache-mode values
| Value | Restore | Save | Typical use |
|---|---|---|---|
| omitted, trusted trigger | yes | yes | push to main, scheduled builds |
| omitted, low-trust trigger | yes | no | the safe default; a save fails with a warning, the job continues |
write | yes | yes | opting a low-trust job back in: only with a reason |
write-only | no | yes | a warm-up job that must never trust what it restores |
read | yes | no | jobs that consume but must not influence others |
none | no | no | jobs that handle credentials or signed artifacts |
Declaring cache-mode: write on a low-trust trigger reintroduces the cache-poisoning risk the default removed, so it belongs only on a job that has been reviewed for it. A called reusable workflow may not request more than its caller granted, which is one more reason to keep cache-mode in the caller.
# Keeps the default-branch cache warm so feature branches and PRs start from a prefix hit.on:schedule:- cron: "17 4 * * *"push:branches: [main]jobs:warm:runs-on: ubuntu-latestcache-mode: write-only # restore nothing, save the fresh treesteps:- uses: actions/checkout@v7- uses: actions/setup-node@v7with:node-version: 24cache: npm- run: npm ci
Limits that decide the key design
Entries not accessed for 7 days are removed. A repository holds 10 GB by default; beyond that GitHub evicts the least recently used entries and the extra storage is billed, and an administrator can raise the limit. Both numbers argue against keying on the commit SHA: a key that changes on every push produces a new entry per push, fills the quota, and starts evicting the entries that were actually being reused. A key that changes with the lockfile produces one entry per dependency change.
The minutes a warm cache returns are usually spent on the gates that were too slow to enable before: a dependency scan on the same lockfile, and image scanning before push. If the workflow that does all of this is shared across repositories, reusable workflows are where the cache-mode decision should live so that fifty callers inherit the safe default.