Speed Up GitHub Actions with Proper Caching
02 Feb 2026An eight-minute CI run is usually seven minutes of reinstalling the exact same dependencies and rebuilding the exact same layers it built last time. Caching fixes most of that, and it’s a few lines of YAML, not a rearchitecture.
Dependency caching
For Node, actions/setup-node has caching built in — you don’t need actions/cache separately:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
This caches ~/.npm keyed on your lockfile hash. Same for setup-python with cache: 'pip', and setup-go caches the module and build cache automatically.
If you’re using a package manager without built-in support, do it manually:
- uses: actions/cache@v4
with:
path: ~/.cache/pnpm-store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
restore-keys is what makes this actually useful. If the exact lockfile hash doesn’t match (you changed one dependency), it falls back to the most recent cache with that prefix instead of a cold install. You still install the one changed package fresh, but everything else comes from cache.
Build output caching
Compiled languages and bundlers benefit more from caching build artifacts than dependencies:
- uses: actions/cache@v4
with:
path: |
.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
Next.js’s own build cache understands incremental builds — if you restore it before next build runs, only changed pages rebuild.
Docker layer caching
This is the one people skip and it’s usually the biggest win if your CI builds a Docker image. Use Buildx with GitHub Actions cache backend:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max caches every layer, including intermediate build stages — not just the final image layers. Without this, every CI run reinstalls every OS package and every dependency from scratch inside the container, even though nothing in your Dockerfile changed.
Order your Dockerfile so things that change rarely (base image, system packages) come before things that change often (your source code):
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
Copying package*.json and running npm ci before copying the rest of the source means that layer only invalidates when dependencies change, not on every commit.
What breaks caching
- Cache key too broad. If your key doesn’t include the lockfile hash, you’ll restore a stale cache and get confusing “works locally, fails in CI” dependency mismatches.
- Cache key too narrow, with no
restore-keysfallback. Every tiny change becomes a full cold install. - GitHub’s 10GB cache limit per repo. Old caches get evicted automatically (LRU), but if you have many branches each writing large caches, you can churn through it fast. Scope caches by branch only where you need branch-specific results; share a common key across branches otherwise.
- Self-hosted runners with local disk. If you’re not using GitHub-hosted runners,
actions/cachestill works but a local Docker registry mirror or a persistent volume mount is often faster than round-tripping to GitHub’s cache storage.
Takeaway
Add dependency caching first — it’s one with: cache: line and cuts install time immediately. Add Docker layer caching second if you build images; it’s usually the bigger win but takes a bit more Dockerfile discipline to get right.