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.

Leave a Comment

Your email address will not be published. Required fields are marked *