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.

May 12, 2026·Updated ·5 min readBeginner·By SecOpsLog · documentation-verified

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.

.github/workflows/ci.yml
- uses: actions/cache@v6
with:
path: ~/.npm
key: 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@v7
with:
node-version: 24
cache: 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.

bash — what a hit and a partial hit look like in the log
Cache restored from key: npm-Linux-a1b2c3…
npm ci ... 11.8s
exact hit: same lockfile, same OS
Cache not found for input keys: npm-Linux-9f8e7d…
Cache restored from key: npm-Linux-a1b2c3… (restore-keys prefix match)
npm ci ... 34.2s
lockfile changed: the old tree was restored, only new packages were fetched, and the new exact key is saved after the job

Who 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

ValueRestoreSaveTypical use
omitted, trusted triggeryesyespush to main, scheduled builds
omitted, low-trust triggeryesnothe safe default; a save fails with a warning, the job continues
writeyesyesopting a low-trust job back in: only with a reason
write-onlynoyesa warm-up job that must never trust what it restores
readyesnojobs that consume but must not influence others
nonenonojobs 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.

.github/workflows/warm-cache.yml
# 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-latest
cache-mode: write-only # restore nothing, save the fresh tree
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
What a cache restores was written by another run
Credentials, .env files and artifacts you intend to deploy or sign do not belong in a cache. A cache is written by whichever run held the key first and read by every run that matches it; the branch scoping and the read-only default limit who that can be, but the job that restores a directory and then executes something from it is trusting the run that wrote it.

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.

Related posts

Quick reference