GitHub Actions Caching Dependencies
Caching saves frequently downloaded files between workflow runs. Every time a workflow runs on a fresh runner, it installs packages like npm or pip dependencies from scratch — which can take several minutes. Caching stores those files on GitHub's servers and restores them on the next run, cutting installation time significantly.
The Problem Caching Solves
Picture a construction crew that needs a toolbox every morning. Without a storage room, the crew drives to the store each morning and buys the same tools again. With a storage room, they grab the same tools from yesterday. Caching is the storage room for your workflow's dependencies.
Without caching (every run):
Run 1: npm install → downloads 200 packages → 3 minutes
Run 2: npm install → downloads 200 packages → 3 minutes
Run 3: npm install → downloads 200 packages → 3 minutes
With caching:
Run 1: npm install → downloads 200 packages → 3 minutes (cache saved)
Run 2: cache restored → npm install → skips downloads → 20 seconds
Run 3: cache restored → npm install → skips downloads → 20 seconds
How GitHub's Cache Works
The actions/cache action works in two phases:
Phase 1 — Cache Restore (start of job):
Check if a cache with the given key exists
↓
If yes → restore files → skip download
If no → proceed normally → download dependencies
Phase 2 — Cache Save (end of job):
If a new cache was not restored → save the files as a new cache
The cache only saves when there was a cache miss. If the same key was restored, nothing new is saved.
Using actions/cache
The three essential inputs are path, key, and optionally restore-keys:
steps:
- uses: actions/checkout@v4
- name: Cache npm packages
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci
Understanding the key
The key is a unique string that identifies a specific cache version. When the key changes, GitHub treats it as a new cache and creates a fresh one.
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
Breaking it down:
npm- → constant prefix (identify this as npm cache)
ubuntu-latest → runner OS (cache is OS-specific)
abc123... → hash of package-lock.json
(changes when dependencies change)
The hashFiles() function generates a unique fingerprint of your lockfile. When you add or update a package, the lockfile changes, the hash changes, and GitHub creates a fresh cache with the new dependencies.
Understanding restore-keys
If the exact key is not found, GitHub tries the restore-keys as fallback prefixes, using the most recent matching cache:
restore-keys: |
npm-ubuntu-latest-
npm-
GitHub searches for caches that start with these prefixes. Even if the exact key missed, you still get a partial cache — better than nothing.
Built-In Caching in Setup Actions
Many actions/setup-* actions include built-in caching. You enable it with a single cache input, which is simpler than using actions/cache directly:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' ← Handles caching automatically
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
For most projects, this built-in approach is the right choice — it handles key generation automatically based on your lockfile.
What to Cache and Where
Package Manager | Path to cache | Key file
----------------|------------------------|-------------------
npm | ~/.npm | package-lock.json
yarn | ~/.yarn/cache | yarn.lock
pip | ~/.cache/pip | requirements.txt
Maven | ~/.m2/repository | pom.xml
Gradle | ~/.gradle/caches | *.gradle files
Go | ~/go/pkg/mod | go.sum
Composer (PHP) | ~/.composer/cache | composer.lock
Cache Limits and Policies
- Each cache entry can be up to 10 GB
- Total cache storage per repository: 10 GB
- Caches unused for 7 days are deleted automatically
- When total size exceeds 10 GB, GitHub evicts the oldest caches first
- Caches from forks cannot access the parent repository's cache
Full npm Caching Example
name: Node.js CI with Cache
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' ← Built-in cache handling
- name: Install dependencies
run: npm ci ← Uses npm cache if available
- name: Run tests
run: npm test
Verifying Cache Hits in Logs
The Actions log shows whether a cache was restored or created:
Cache hit on key: npm-Linux-abc123...
✓ Restored from cache
Cache miss on key: npm-Linux-xyz789...
→ Running npm ci to install packages
→ Saving new cache entry
A consistent stream of "Cache hit" messages means your caching is working correctly and saving runner minutes on every run.
