Stop re-downloading the same packages on every CI run by caching dependencies with keys that actually hit.
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.
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.
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.
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.
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.
Cache the package manager's download or global store, not the installed project directory:
~/.npm, pnpm store), not node_modules.~/.cache/pip.~/.cache/go-build and ~/go/pkg/mod.~/.gradle/caches, ~/.m2/repository.~/.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:
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
go-${{ runner.os }}-
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.
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.
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 latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
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.
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.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.