Dependency Installation and Dependency Caching
Installing dependencies is usually the slowest predictable step in a CI job. Every fresh runner starts with an empty disk, so npm ci, pip install, or bundle install has to fetch every package over the network before a single test runs. Dependency caching reuses previously downloaded packages between workflow runs so that repeat installs are fast, while still keeping the install step itself as the source of truth for what actually gets used.
Overview / How it works
This is a continuous integration concern: caching does not change what gets built or deployed, it only changes how quickly a run gets from checkout to a runnable dependency tree. GitHub Actions gives you two related mechanisms. The low-level one is the actions/cache action, which saves and restores an arbitrary directory keyed by a string you construct. The higher-level one is the built-in cache input on setup actions like actions/setup-node, actions/setup-python, and actions/setup-java, which wraps actions/cache with sane defaults for a specific package manager.
A cache entry is identified by a key. When a job starts, GitHub Actions looks for an exact match of that key. If found, the directory is restored and the install step becomes mostly a no-op. If not found, the step runs as normal, and at the end of the job GitHub Actions saves a new cache entry under that key for future runs to find. Caches are scoped per repository and, for read access during a pull request, limited to the current branch and its base branch — a workflow cannot read or poison a cache that belongs to an unrelated branch or a different fork.
Syntax or workflow structure
The core building block is actions/cache, used as a step before your install command:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-npm-
Three parts matter here. path is the directory being cached — almost always the package manager’s own download cache, not the installed dependency tree itself. key is what must match exactly for a cache hit; including hashFiles() over the lock file means a new key is generated automatically whenever dependencies change. restore-keys is a fallback list: if no exact key matches, GitHub Actions restores the most recent cache whose key starts with one of these prefixes, giving you a close-but-not-identical cache rather than nothing.
Examples
Example 1: No caching (baseline). A minimal Node.js job installs dependencies from scratch on every run.
name: CI
on: push
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
Expected behavior: every single run, regardless of whether package-lock.json changed, downloads every package tarball from the registry. On a mid-sized project this routinely adds 30 to 90 seconds to each job, multiplied by every job in the matrix.
Example 2: Manual caching with actions/cache. The same job, now caching npm’s own download cache.
name: CI
on: push
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-npm-
- run: npm ci
- run: npm test
Expected behavior: the first run on a new key logs a cache miss and, after the job finishes successfully, uploads a new cache entry. Every subsequent run with an unchanged package-lock.json logs a cache hit; npm ci still runs and still validates the lock file, but it resolves packages from the restored local cache instead of the network, so the step finishes noticeably faster.
Cache not found for input keys: Linux-node20-npm-3f9a1b2c...
...
Cache saved with key: Linux-node20-npm-3f9a1b2c...
(next run, lock file unchanged)
Cache restored from key: Linux-node20-npm-3f9a1b2c...
Example 3: Built-in caching with a matrix. setup-node can manage the cache itself, including keying it per Node.js version in a matrix.
name: CI Matrix
on: push
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
Expected behavior: setup-node derives a cache key from the runner OS, the Node.js version, and a hash of the lock file, so the Node 18 and Node 20 legs get separate, correctly isolated cache entries instead of overwriting each other. For Python projects, actions/setup-python offers the same pattern with cache: 'pip', keyed against requirements.txt or poetry.lock.
Step by step
- The job starts and checks out the repository.
- A cache step (manual or built into a setup action) computes the cache key, typically combining the OS, runtime version, and a hash of the lock file.
- GitHub Actions searches for an exact key match. On a hit, it downloads and extracts the cached directory before the install command runs.
- If there is no exact match, it tries each
restore-keysprefix in order and restores the newest matching entry, if any. - The install command (
npm ci,pip install -r requirements.txt, etc.) runs regardless of cache outcome — the cache only changes where packages come from, never whether the install step itself runs. - If the job succeeds and no exact-key cache existed at the start, a new cache entry is saved under that key at the end of the job.
Common Mistakes
Mistake 1: caching the installed dependency directory instead of the package manager’s download cache.
- uses: actions/cache@v4
with:
path: node_modules
key: node-modules-cache
Caching node_modules directly skips npm ci entirely on a hit, which means native binary addons built for one runner image can silently break on another, and the lock file is never re-validated. Cache the package manager’s own store instead and always run the install command:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}
Mistake 2: using a static key with no lock file hash.
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-cache
With a key that never changes, the cache is written once and then reused forever, even after package-lock.json is updated with new dependency versions. CI keeps testing against stale, cached packages that no longer match the lock file, which can hide real dependency issues until they surface in production. Always derive the key from hashFiles() over the relevant lock file, as shown in Example 2, so a dependency change automatically invalidates the cache.
Best Practices
- Cache the package manager’s download cache (
~/.npm,~/.cache/pip,~/.cache/yarn), not the final installed tree — always let the install command rebuild that tree. - Derive the cache key from a hash of the lock file, plus OS and runtime version, so the cache updates automatically and never crosses incompatible environments.
- Use
restore-keysas a fallback so a new branch or a bumped lock file still gets a partial cache instead of a fully cold install. - Set
permissions: contents: readexplicitly on jobs that only checkout and cache — caching does not need write access to repository contents. - Remember that caches are scoped to the current branch and its base branch; a workflow run cannot read a cache written by an unrelated branch or an external fork, which limits (but does not eliminate) cache-poisoning risk on repositories that also run workflows from forked pull requests.
- Do not cache files containing credentials, tokens, or generated secrets — cache contents are restored verbatim on every hit.
- Repository-wide cache storage is limited (currently 10 GB) with least-recently-used eviction, so scope keys tightly rather than caching entire toolchains under one broad key.
- Pin
actions/cacheand setup actions to a specific major version tag such as@v4; pin to a commit SHA instead in security-sensitive pipelines to prevent an upstream tag from being repointed to different code.
Practice Exercises
- Take an existing workflow that runs
npm ciwith no caching and add anactions/cachestep keyed onrunner.os, the Node.js version, andhashFiles('**/package-lock.json'). Confirm in the run logs that the first run reports a cache miss and a later run with no lock file changes reports a cache hit. - Modify a dependency version in your lock file and push again. Verify in the logs that the cache key changed and a fresh cache entry was created, rather than the install silently reusing outdated packages.
- Convert a workflow using manual
actions/cachefor Node.js to use the built-incache: 'npm'input onactions/setup-nodeinstead, and compare the generated key and log output between the two approaches.
Summary
Dependency caching in GitHub Actions trades a small amount of setup complexity for a large, repeatable reduction in CI time. The safe pattern is consistent across ecosystems: cache the package manager’s own download store, key it by OS, runtime version, and a hash of the lock file, and always let the install command run so the cache can never mask a real dependency change. Caching should make CI faster, never change what CI actually verifies.
