12 Oct, 2026Nitesh Jangid
GitHub Actions Cache Guide: Setup, Keys and Examples
Learn how to set up GitHub Actions cache with practical YAML examples, choose cache keys, troubleshoot misses, and improve CI build performance.
How to Set Up and Use GitHub Actions Cache
Every CI run should test your latest code. It should not need to download the same dependencies from scratch every time.
GitHub Actions cache lets workflows reuse files across runs, reducing repeated downloads and other expensive setup work. The useful part is choosing what to cache, when to reuse it, and how to keep builds correct when dependencies change.
This guide walks through a working npm example, explains cache matching, and shows how to measure the result. It also covers where runner infrastructure, including Monk CI, fits into cache performance.
What is GitHub Actions cache
GitHub Actions cache stores reusable files, such as dependency downloads and intermediate build data, so later workflow runs can restore them. This is particularly useful on GitHub-hosted runners, where jobs normally start with a clean environment.
A cache should be optional: your workflow must still install dependencies and build successfully when no cached data exists.
Use artifacts for outputs you need to retain or pass between jobs, such as release binaries, test reports, and deployment bundles. Use caches to accelerate work that can be repeated. GitHub’s dependency caching overview
Set up caching with actions/cache
The main inputs are:
| Input | Purpose |
| path | Files or directories to restore and save |
| key | Identifier for the cache you want |
| restore-keys | Ordered fallback prefixes when the primary key does not match |
For a Node.js project with a root-level package-lock.json, create .github/workflows/ci.yml:
name: Node CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
# This workflow manages caching explicitly below.
package-manager-cache: false
- name: Find npm cache directory
id: npm-cache-dir
run: echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Restore npm download cache
id: npm-cache
uses: actions/cache@v5
with:
path: ${{ steps.npm-cache-dir.outputs.path }}
key: npm-v1-${{ runner.os }}-${{ runner.arch }}-node24-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-v1-${{ runner.os }}-${{ runner.arch }}-node24-
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm testThis assumes your project defines an npm test script. Add your build command after installation if needed.
The example uses actions/cache@v5, which runs on Node.js 24 and requires Actions Runner 2.327.1 or newer. Check runner compatibility when maintaining your own runner fleet. The action’s runtime is separate from the Node version your application uses. actions/cache documentation
The setup step disables automatic npm caching to avoid managing the same directory twice. setup-node documentation
Why cache npm downloads instead of node_modules
The cached directory contains npm’s reusable download data. It does not represent an installed application.
Keep npm ci running even after an exact cache hit. It installs dependencies from the committed lockfile and removes an existing node_modules directory before installation. Restoring node_modules only to have npm ci delete it adds unnecessary work. npm ci documentation
After restoring downloads, installation may still spend time unpacking packages, running lifecycle scripts, and compiling native dependencies. A cache hit does not eliminate those costs.
How cache restore and save work
For an eligible branch scope, GitHub searches for an exact primary key, then primary-key prefix matches, then the supplied restore keys in order. It can repeat the search on the default branch, subject to access restrictions. Among prefix matches, the newest matching entry wins.
Cache identity also includes an internal version derived from paths and compression. Identical visible keys alone do not guarantee compatibility. GitHub’s cache matching reference
With the combined actions/cache action, restoration happens at the cache step. If there was no exact hit, its post-job phase saves the selected paths under the primary key after a successful job, provided cache writes are allowed. An existing entry is not updated in place. actions/cache behavior
For the workflow above, think through three runs:
- First run: npm downloads the dependencies; a successful job populates the cache.
- Unchanged lockfile: the workflow restores the matching downloads, then runs npm ci and tests.
- Changed lockfile: the fallback can supply reusable downloads; npm fetches anything missing, and the new primary key identifies the refreshed cache.
Design cache keys around compatibility
The example key has five components:
| Component | Why it is included |
| npm | Separates this cache from other tools |
| v1 | Gives you a manual reset mechanism |
| runner.os and runner.arch | Separates execution environments |
| node24 | Makes the runtime boundary explicit |
| Lockfile hash | Changes the key when dependencies change |
This is a conservative starting point. A download cache may be reusable across more environments than compiled outputs. For native binaries or build outputs, also account for compiler versions, build flags, and other inputs that affect compatibility.
Avoid using a commit SHA as the only dependency-cache identifier: unrelated source changes would create new entries. Avoid a permanent key such as npm-cache, too: it does not describe dependency changes.
Choose narrow restore keys
The fallback in this example retains the tool, cache generation, OS, architecture, and runtime. It removes only the lockfile hash.
That lets a dependency update reuse earlier downloads within the same environment. A fallback as broad as ${{ runner.os }}- makes it harder to reason about what you might restore.
Treat fallback data as a starting point. Always let the package manager or build tool validate and complete it.
Check the files being hashed
hashFiles('package-lock.json') addresses the repository-root lockfile. For a project inside apps/web, use hashFiles('apps/web/package-lock.json') and run installation in that directory.
Check out the repository before evaluating file hashes. When no files match, hashFiles() returns an empty string, so a wrong path can silently remove the dependency fingerprint from your key. GitHub’s hashFiles reference
Understand cache hits and misses
The action exposes a string output called cache-hit:
| Output | Meaning |
| 'true' | Exact match to the primary key |
| 'false' | A fallback or non-exact cache was restored |
| Empty string | No cache was restored |
Use steps.npm-cache.outputs.cache-hit != 'true' when you need to handle everything other than an exact hit. Avoid testing only for 'false', because that excludes an empty output. actions/cache outputs
For example, this diagnostic step can follow the cache step:
- name: Report cache result
env:
CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: |
if [ "$CACHE_HIT" = "true" ]; then
echo "Exact npm cache hit"
elif [ "$CACHE_HIT" = "false" ]; then
echo "Fallback npm cache restored"
else
echo "No npm cache restored"
fiDo not use this output to skip installation when the cached content is only package downloads.
A shorter alternative with setup-node
For a conventional npm project, built-in caching reduces configuration. Use these steps instead of the explicit npm cache steps:
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm testsetup-node manages the package-manager cache and derives a key from the dependency file. It does not cache node_modules. Choose explicit actions/cache configuration when you need custom paths or matching rules. Built-in npm caching
For an independently installed application in a monorepo, adapt it like this:
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: apps/web/package-lock.json
- run: npm ci
working-directory: apps/web
- run: npm test
working-directory: apps/webIf the monorepo uses npm workspaces with one root lockfile, keep installation and the cache dependency path at the workspace root instead.
Best practices and common issues
Keep cache contents focused
Start with one expensive, reusable directory. Avoid archiving the entire workspace or home directory. Split caches when their contents have different invalidation rules, such as dependency downloads versus framework build data.
Never include credentials or secret-bearing configuration. Treat restored files as untrusted input, especially across workflows with different privileges. GitHub’s cache security guidance
Check scope before changing keys
Default-branch caches can be reused by other branches. Pull-request runs can also access their base branch, but caches created for a pull-request merge ref are restricted to that ref. Matching keys do not make all branch caches interchangeable. Cache access restrictions
Diagnose the symptom
| Symptom | What to check |
| Every run restores nothing | Inspect the resolved key, branch scope, and saved entry; confirm that an earlier save succeeded. |
| The hash portion is empty | Verify the lockfile path and checkout order. |
| Cache hit but packages are unavailable | Confirm you still run installation; downloaded packages are not an installed dependency tree. |
| Cache save does not happen | Read the post-job logs; check job success, directory contents, and cache-write access. |
| Old data keeps returning | Update the key generation prefix, including its restore prefixes, or delete the specific entry. |
| Caches disappear frequently | Inspect storage usage and short-lived branch entries for eviction pressure. |
| A hit makes the job slower | Compare restore and extraction time with the work actually avoided. |
GitHub defaults to 10 GB of cache storage per repository; configurable increases can incur charges. Entries unused for more than seven days are removed. Cache limits and eviction
Use Actions → Caches to inspect entry sizes, keys, and last-access times. You can filter by branch and delete individual stale entries. This is more useful than repeatedly clearing every cache without identifying why it misses. Managing GitHub Actions caches
Measure and optimize cache performance
The goal is a faster successful workflow, not a higher hit count.
Measure three situations: no cache, an exact hit, and a fallback after a dependency change. Record restore time, installation time, build and test duration, post-job save time, and total elapsed time.
For example, these are illustrative measurements, not benchmark results:
| Dependency setup component | Without caching | Warm cache |
| Restore and extract | 0 seconds | 12 seconds |
| Install dependencies | 90 seconds | 30 seconds |
| Total | 90 seconds | 42 seconds |
Here, caching saves 48 seconds during dependency setup. If extraction instead took 70 seconds, the warm setup would take 100 seconds and be slower than the baseline.
For cold and fallback runs, include the upload cost as well. Repeat measurements on comparable runner sizes and report a median rather than selecting the fastest run.
Practical improvements include:
- Remove directories that cost little to regenerate.
- Reduce unnecessary key changes that create nearly identical archives.
- Separate large caches so jobs restore only what they use.
- Investigate CPU and disk bottlenecks when extraction or installation dominates.
- Measure the workflow’s critical path; accelerating a non-blocking job may not shorten time to completion.
Docker builds need their own cache strategy
Caching npm downloads on the runner does not automatically cache dependencies installed inside a Docker build. Configure BuildKit caching separately.
For a workflow building a repository-root Dockerfile, a minimal build-only example is:
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Build image with reusable layers
uses: docker/build-push-action@v7
with:
context: .
push: false
cache-from: type=gha,scope=web-image
cache-to: type=gha,scope=web-image,mode=maxThis exports reusable build data to the GitHub Actions cache backend. Use different scopes for independent images. A read-only workflow should omit cache-to; it can still restore through cache-from. Docker’s GitHub Actions caching guide
Restore GitHub Actions cache 4× faster with Monk CI
Once your cache keys and paths are configured, the time spent restoring cached data becomes another opportunity to speed up CI.
Monk CI provides 4× faster Actions cache restores compared with GitHub’s cache, with no separate code changes beyond changing your runner label. Its cache backend sits on the same high-speed network as its runners, reducing the time spent transferring cached dependencies. Monk CI caching documentation
After connecting your repository to Monk CI, change the job’s runs-on label:
# Before
runs-on: ubuntu-latest
# After
runs-on: monkci-ubuntu-latestThese are alternative values for the same field; replace the existing line rather than adding both.
Your existing actions/cache steps, cache paths, keys, and restore keys continue to work. Supported built-in caching in actions such as setup-node also benefits automatically. There is no additional caching action to install or separate cache configuration to maintain.
Monk CI also provides faster runner compute and persistent Docker layer caching to reduce the work that remains after dependency restoration. Measure cache restore time and total job duration on your own workflows to see the impact.
For workflows already using GitHub Actions cache, changing the runner label is the only workflow code change needed to use Monk CI’s faster caching.