Skip to content

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 ~/.npm in every job that runs npm 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-keys to 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.

# On an exact miss, fall back to the most recent cache with a matching prefix
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      deps-${{ runner.os }}-
A partial hit still saves most of the download time; npm fetches only what changed.

- name: Cache dependencies
  id: cache
  uses: actions/cache@v4
  with:
    path: node_modules
    key: modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
  if: steps.cache.outputs.cache-hit != 'true'
  run: npm ci
Only worth it when caching the installed tree itself — and that carries the platform-binary caveat above.

# setup-node wires up ~/.npm caching with a lockfile-derived key for you
- uses: actions/setup-node@v4
  with:
    node-version: 18
    cache: 'npm'
Prefer this for plain npm/yarn/pnpm projects; use actions/cache when you need custom paths or keys.

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      ~/.cache/Cypress
    key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# pip
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}

# Maven
- uses: actions/cache@v4
  with:
    path: ~/.m2/repository
    key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}

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-keys with 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 of setup-node, setup-python, or setup-java for standard setups; drop to actions/cache only for custom needs.
  • Cache the package manager's download cache, not node_modules.
  • Check the official actions/cache examples 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-credentials can 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-keys be so loose that an unrelated cache matches. An over-broad prefix restores content you never intended.
  • Treat a cache as untrusted input: npm ci still verifies against the lockfile, which is part of why caching ~/.npm is safer than caching node_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_modules across 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-hit conditional.
  • Wrong path for the OS. ~/.npm is right on Linux and macOS; Windows uses a different location.
  • Hashing the wrong file. Hashing package.json instead of package-lock.json misses 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/cache takes a path to store and a key to 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), not node_modules.
  • Cache entries are immutable; restore-keys provides 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-keys strategies, per- ecosystem cache paths, built-in caching in setup-* actions, cache scoping and eviction limits, and cache-poisoning defenses.