3.04 Caching Dependencies
Overview
Installing dependencies is usually the slowest repeated step in a pipeline. actions/cache stores
the package manager's download cache in GitHub's cloud so later jobs and later runs can reuse it.
Abstract
Add a cache step before the step that produces the files being cached. It takes a path
(the folder to store — ~/.npm for npm, not node_modules) and a key that must contain a
dynamic element, conventionally hashFiles('**/package-lock.json'). When the lockfile changes,
the hash changes, the key misses, and the dependencies are re-downloaded. The action also runs
automatically after the job as a post step, saving the folder if the key wasn't already
stored. The cache is central: one store shared across jobs in a workflow and across workflow
runs.
Why It Matters in Production
Jobs chained with needs run sequentially, so the workflow's duration is the sum of its jobs plus
platform overhead. Checkout costs about a second; npm ci costs ten to fifteen. Repeat that across
three jobs and dependency installation dominates the run.
Cutting it matters even on the free plan, because deploy latency is how fast a fix reaches users. On paid plans it is money: runner minutes are billed, and installing the same packages three times per run is pure waste.
The trade-off is correctness. A cache key that never changes serves stale dependencies forever; one that changes too often never hits. The key is the whole design.
Key Concepts
| Input / output | Meaning |
|---|---|
path |
File(s) or folder(s) to cache — one per line for several |
key |
Exact identifier; a hit restores the cache, a miss triggers a save after the job |
restore-keys |
Ordered prefixes tried on a miss, for a partial (stale but useful) hit |
cache-hit |
Step output: 'true' on an exact key match |
| Post step | Automatically added; saves the folder after the job if the key was new |
What to cache per ecosystem
| Ecosystem | Cache this | Not this | Key from |
|---|---|---|---|
| npm | ~/.npm |
node_modules |
package-lock.json |
| Yarn | ~/.cache/yarn (or .yarn/cache) |
node_modules |
yarn.lock |
| pnpm | the pnpm store path | node_modules |
pnpm-lock.yaml |
| pip | ~/.cache/pip |
the virtualenv | requirements.txt |
| Maven | ~/.m2/repository |
target/ |
pom.xml |
| Gradle | ~/.gradle/caches |
build/ |
*.gradle* |
| Go | ~/go/pkg/mod |
— | go.sum |
Cache the download cache, not the installed tree
npm already maintains its own cache in ~/.npm; what the action adds is persisting that folder
beyond the runner. npm ci still runs every time and still does its install work, but it
sources packages locally instead of over the network. Caching node_modules directly is
fragile — it embeds platform-specific binaries and bypasses the lockfile's guarantees.
Cache entries are immutable
A key that already exists is never overwritten. If a key is too static, the first version stored is the one every future run gets, regardless of what changed.
Cache vs artifact
| Cache | Artifact | |
|---|---|---|
| Purpose | Speed optimization | Deliverable or handoff |
| Guaranteed present | No — may miss or be evicted | Yes, until retention expires |
| Scope | Across jobs and workflow runs | The run that produced it (by default) |
| Correct behavior on miss | Rebuild/redownload silently | Failure — something is wrong |
Common Use Cases
- Cache
~/.npmin every job that runsnpm ci - Cache the pip or Maven local repository in multi-job Python/Java pipelines
- Cache a build tool's incremental output (
.next/cache, Gradle build cache) between runs - Cache large downloaded toolchains that
setup-*actions don't handle - Use
restore-keysto fall back to a near-match when a single dependency changed
Example Configuration or Commands
The cache step sits before the install step, in every job that installs:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Lint code
run: npm run lint
- name: Test code
run: npm run test
build:
needs: test
runs-on: ubuntu-latest
outputs:
script-file: ${{ steps.publish.outputs.script-file }}
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies # same path and key — same central cache
uses: actions/cache@v4
with:
path: ~/.npm
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Build website
run: npm run build
The first run reports no cache found, then saves it in the automatic post step. The next job in the same run already hits it — install drops from roughly twelve seconds to four.
# setup-node wires up ~/.npm caching with a lockfile-derived key for you
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
actions/cache when you need custom paths
or keys.
Why the key needs a dynamic part
key: deps-node-modules never changes, so the first cache stored is served forever — bumping a
dependency in package-lock.json would have no effect on CI. Including
hashFiles('**/package-lock.json') makes the key change exactly when the dependency set
changes, which is the condition under which the cache must be discarded.
Inspecting caches:
# List caches with size, key, and last use
gh cache list --limit 20
# Delete a poisoned or oversized cache
gh cache delete <cache-id>
# Via the REST API
gh api repos/OWNER/REPO/actions/caches --jq '.actions_caches[] | "\(.key)\t\(.size_in_bytes)"'
Best Practices
Best Practices
- Always include a lockfile hash in the key. A static key is worse than no cache.
- Include
runner.os(and the language version, in a matrix) so Linux and Windows caches don't collide. - Add
restore-keyswith a prefix so a changed lockfile still gets a partial hit. - Put the cache step before the install step in every job that installs — they share one central store, so the second job benefits from the first.
- Prefer the
cache:input ofsetup-node,setup-python, orsetup-javafor standard setups; drop toactions/cacheonly for custom needs. - Cache the package manager's download cache, not
node_modules. - Check the official
actions/cacheexamples for your ecosystem's correct path — they differ by OS and tool. - Keep cached content small. Large caches take time to upload and download, and can cost more than they save.
Security Best Practices
Security
- Never cache credentials.
~/.npmrc,~/.docker/config.json,.aws/credentials, and.git-credentialscan all contain tokens written during a run. Cache specific directories, never a home directory wholesale. - Cache poisoning is real. A pull request branch can write a cache that the default branch later restores, so a malicious PR can plant content that a trusted workflow consumes. Don't cache executables or build output a deploy job will run; restrict caching on untrusted triggers.
- Caches are scoped by branch: a run restores caches from its own branch, its base branch, and the default branch. That scoping limits the blast radius but does not eliminate it.
- Never cache secrets or decrypted material "to save time". Cache contents are retrievable by anyone who can run workflows in the repository.
- Don't let
restore-keysbe so loose that an unrelated cache matches. An over-broad prefix restores content you never intended. - Treat a cache as untrusted input:
npm cistill verifies against the lockfile, which is part of why caching~/.npmis safer than cachingnode_modules. - Delete caches after any incident —
gh cache delete— rather than waiting for eviction.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
Put hashFiles() in the key |
Use a static key like deps-node-modules |
Cache ~/.npm |
Cache node_modules |
| Add the cache step before install | Add it after the files exist |
| Repeat the step in every installing job | Assume one job's cache step covers the rest |
Include runner.os in the key |
Share one key across operating systems |
Add restore-keys for partial hits |
Accept a full re-download on any lockfile change |
Use setup-node's cache: for simple cases |
Hand-roll what the setup action already does |
| Cache specific directories | Cache ~ or the whole workspace |
Common Mistakes
Common Mistakes
- Static key. The cache is stored once and never refreshes — entries are immutable.
- Caching
node_modulesacross platforms. Native binaries compiled for one OS or Node version break on another. - Cache step after the install step. The post step still saves, but nothing is restored beforehand, so the first install in each job is always cold.
- Expecting the install step to be skipped. It still runs; it is just faster because the
download cache is present. Skipping requires the
cache-hitconditional. - Wrong path for the OS.
~/.npmis right on Linux and macOS; Windows uses a different location. - Hashing the wrong file. Hashing
package.jsoninstead ofpackage-lock.jsonmisses transitive dependency changes. - No
restore-keys. One changed dependency discards the entire cache. - Caching huge directories. Upload and download can exceed the time the cache saves.
- Expecting a cross-branch hit. A feature branch cannot restore another feature branch's cache.
Breaks in production
A static cache key plus a cached node_modules means CI keeps testing against the dependency
versions from whenever the cache was first created. A security patch lands in the lockfile,
every check stays green, and the vulnerable version is still what builds and ships.
Troubleshooting
# Which caches exist, how big, and when were they last used?
gh cache list --limit 20
# Remove a stale or poisoned entry
gh cache delete <cache-id>
# Confirm the key your expression produces — echo it in a step
# run: echo "deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}"
# Compare install timings between jobs in a run
gh run view <run-id> --json jobs --jq '.jobs[] | {name, startedAt, completedAt}'
| Symptom | Likely cause |
|---|---|
| "Cache not found for input keys" on every run | Key changes each run, or the post step never saved |
| Cache hits but install is still slow | Wrong path cached — npm isn't finding its store |
| Stale dependencies in CI | Static key, immutable entry serving old content |
| Hit on one OS, miss on another | runner.os missing from the key |
| Post step reports "Cache already exists" | Expected — the key was stored by an earlier job |
| Restore pulls unexpected content | restore-keys prefix too broad |
| Cache never hits on a new branch | Branch scoping — only own, base, and default branch caches |
| Repository near the cache size limit | Old entries accumulating; evict or shrink what's cached |
Tip
The run log tells the whole story. The cache step reports whether it found an entry, and the automatically added Post Cache dependencies step reports the key it saved under. Comparing install durations between the first and second job of the same run is the quickest proof the cache is working.
Quick Recap
- Dependency installation is usually the longest repeated step; caching targets it.
actions/cachetakes apathto store and akeyto identify it.- Place the cache step before the step that creates the files.
- The key must contain a dynamic element —
hashFiles('**/package-lock.json')is the convention. - A changed lockfile changes the hash, misses the key, and forces a refresh.
- The action adds an automatic post step that saves the folder after the job.
- One central cache is shared across jobs and across workflow runs.
- Repeat the cache step in every job that installs dependencies.
- Cache the package manager's download folder (
~/.npm), notnode_modules. - Cache entries are immutable;
restore-keysprovides partial hits. - A cache is an optimization — a miss must be harmless.
Interview / Revision Notes
Quick revision
Q: Which action caches files?
actions/cache.
Q: Which two inputs are required?
path and key.
Q: Where does the cache step go? Before the step that produces the cached files.
Q: Why must the key be dynamic? So the cache is discarded when dependencies change — entries are immutable and never overwritten.
Q: What does hashFiles() do?
Produces a hash over the matching files; it changes whenever their contents change.
Q: What is cached for npm?
~/.npm, npm's own download cache — not node_modules.
Q: When is the cache saved? In an automatically added post step after the job, if the key wasn't already stored.
Q: Is the cache per job? No — one central cache, shared across jobs and across workflow runs.
Q: Does caching skip the install step?
No. The install runs but sources packages locally. Skipping requires cache-hit in an if:.
Q: What are restore-keys for?
Prefix fallbacks that allow a partial hit when the exact key misses.
Q: Cache vs artifact? A cache is a best-effort speed optimization; an artifact is a guaranteed, downloadable deliverable.
This topic can be expanded into sub-pages: cache keys and
restore-keysstrategies, per- ecosystem cache paths, built-in caching insetup-*actions, cache scoping and eviction limits, and cache-poisoning defenses.