Blog
4 min read

GitHub Actions Caching: Faster CI With setup-node, actions/cache and cache-mode

How to make GitHub Actions workflows faster with caching: the built-in cache option in setup-node and setup-python, actions/cache for anything else, good cache keys, Docker layer caching, the new cache-mode setting that limits cache access to prevent cache poisoning, and why caches miss.

Every GitHub Actions run starts on a fresh machine. Without caching, every run downloads all your dependencies again. A good cache often cuts minutes off each run. (Set Up CI With GitHub Actions)

The easy way: setup actions with built-in caching

For most Node.js and Python projects, you don't need actions/cache at all:

- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: npm          # or pnpm / yarn
- run: npm ci
- uses: actions/setup-python@v7
  with:
    python-version: "3.14"
    cache: pip
- run: pip install -r requirements.txt

The cache key is derived from your lockfile (package-lock.json, pnpm-lock.yaml, requirements.txt), so it refreshes when dependencies change. Note that it caches the package manager's download cache, not node_modules itself — npm ci still runs, just much faster.

actions/cache for everything else

For build caches, tool downloads or anything custom:

- uses: actions/cache@v6
  with:
    path: |
      ~/.cache/ms-playwright
    key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      playwright-${{ runner.os }}-
  • key — an exact match restores this cache; on a miss, a new cache is saved at the end of the job under this key.
  • restore-keys — fallback prefixes: if there's no exact match, restore the most recent cache that starts with this, which is usually better than nothing.

Other good candidates: Next.js's .next/cache, Turborepo's cache, Go and Rust build caches.

Writing good keys

  • Include the OS (runner.os) — caches built on Linux don't work on macOS.
  • Include a hash of whatever invalidates it — usually a lockfile.
  • Bump a manual version prefix (v2-npm-...) when you need to force a fresh cache.

Docker layer caching

With docker/build-push-action, use the GitHub Actions cache backend:

- uses: docker/build-push-action@v7
  with:
    push: true
    tags: ghcr.io/you/app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

That reuses unchanged layers across runs — combined with a well-ordered Dockerfile, it makes image builds much faster. (Production Dockerfile for Node)

cache-mode: controlling who can write the cache

A cache is shared between workflow runs. That's a security risk: a workflow triggered by untrusted input — like pull_request_target from a fork — could write a poisoned cache that a later trusted run (say, your release build) restores. This is called cache poisoning.

In September 2026 GitHub made cache-mode generally available to limit cache access per workflow or job:

Value Restore Save
read ✅ ❌
write ✅ ✅
write-only ❌ ✅
none ❌ ❌
cache-mode: read          # whole workflow

jobs:
  release:
    cache-mode: none      # job-level overrides workflow-level
    runs-on: ubuntu-latest

By default, low-trust triggers like pull_request_target already get read, and trusted ones like push get write. Explicitly granting write to a low-trust trigger brings the risk back, and GitHub adds a warning. A disallowed cache operation just logs a message — it doesn't fail the job.

For release and deploy jobs that must not consume potentially poisoned caches, use cache-mode: none. read prevents the job from saving a cache, but still restores caches written by other runs; it doesn't establish that those caches are trustworthy. (npm Supply Chain Security)

Why caches miss

  • Branch scoping: a run can restore caches from its own branch and the default branch, but not from sibling feature branches. The first run on a new branch builds from main's cache.
  • Eviction: repositories have a cache storage limit, and least-recently-used caches are evicted, as are caches that haven't been used for a while.
  • Key changes every run because something in the key (a timestamp, a non-deterministic file) changes.
  • Caches are immutable: a key that already exists isn't overwritten. Change the key to update it.

The summary

  • Use cache: in setup-node/setup-python first; actions/cache for everything else.
  • Keys = OS + lockfile hash; add restore-keys for partial hits.
  • Use cache-mode to stop untrusted workflows writing caches your releases trust.

EasySpawn runs Claude Code on a persistent server where dependencies stay installed between sessions, and works with your GitHub Actions CI to verify every pull request. See how it works or join the waitlist.

Related: GitHub Actions Secrets · GitHub Actions: Deploy to a VPS · Claude Code GitHub Actions · What Is CI/CD?

Keep reading