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:insetup-node/setup-pythonfirst;actions/cachefor everything else. - Keys = OS + lockfile hash; add
restore-keysfor partial hits. - Use
cache-modeto 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
Scheduled GitHub Actions: Run Workflows on a Cron Schedule
How to run a GitHub Actions workflow on a schedule with on.schedule and cron syntax: examples, the new timezone option, why scheduled runs start late or get skipped, the 60-day inactivity rule, running on demand with workflow_dispatch, and what to use instead when timing really matters.
Writing a systemd Service File for Your App (With Hardening)
A production-ready systemd unit for a Node.js or Python app, explained: Type, User, WorkingDirectory, EnvironmentFile, Restart and backoff, graceful stop, resource limits, logging, and the sandboxing options (ProtectSystem, NoNewPrivileges, PrivateTmp) that limit damage if the app is compromised.