CI minutes are one of those costs that creep. A pipeline that took four minutes when the repo was young takes eleven minutes two years later, nobody remembers when that happened, and suddenly every pull request review cycle includes a coffee break. When I audited the workflows behind my client projects and my own apps, the single biggest lever was not parallelism or bigger runners — it was caching done deliberately instead of cargo-culted.
This post covers the three cache layers that matter in a typical GitHub Actions pipeline — package manager dependencies, Docker image layers, and framework build caches — with the exact configurations I run, the platform limits that silently sabotage naive setups, and the anti-patterns I keep finding in real repos. Most teams can cut 30 to 60 percent of their CI wall time with an afternoon of work here.
How GitHub's Cache Actually Behaves
Before optimizing anything, internalize the platform rules, because they shape every strategy decision:
- Each repository gets 10 GB of cache storage by default. Past that, GitHub evicts the oldest caches first — your slow-but-rebuilt-rarely cache can be evicted by your fast-but-rebuilt-constantly one.
- Any cache entry not accessed for 7 days is deleted. A weekly-only release workflow effectively never has a warm cache unless something else keeps the key alive.
- Lookup order is exact key match first, then restore-keys top to bottom as prefix matches. A restore-keys hit still counts as a miss for the primary key, so a fresh cache gets saved at job end.
- Caches created on a branch are visible to that branch and its children, plus the default branch caches are visible everywhere. Warm your caches on main and every feature branch benefits.
Layer 1: Dependency Caching, the Cheap 80 Percent
For Node projects, the built-in caching in setup-node covers most needs with one line — it hashes your lockfile and caches the package manager's global store. Reach for the manual actions/cache approach only when you cache beyond dependencies, like the Next.js incremental build cache:
# 90% of Node projects only need this
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm" # also accepts yarn / pnpm
# manual control when you cache more than the package manager
- uses: actions/cache@v4
with:
path: |
~/.npm
.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
nextjs-${{ runner.os }}-The two-level restore-keys fallback is the part most people skip. With it, a lockfile change still restores the previous cache and only downloads the delta; without it, every dependency bump is a cold npm install. On a mid-size Next.js app this is the difference between a 40-second and a 3-minute install step.
Measure before and after: the cache step logs its hit/miss and the restored key. I add a one-line job summary printing cache status, because a silent string of partial misses looks identical to working caching unless you check.
Layer 2: Docker Layer Caching with BuildKit
If your workflow builds images, this layer dwarfs everything else. By default, every CI build starts from zero — no layer cache exists on a fresh runner. BuildKit's GitHub Actions cache backend fixes that with two lines:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/you/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxThe type=gha backend stores layer blobs in the same cache service your dependencies use, which means they share the 10 GB budget. The mode=max flag caches intermediate layers from every build stage, not just the final image, which is what makes multi-stage builds fast. For bigger images there is a second option worth knowing:
type=gha cache
Zero infrastructure, ideal for app images under roughly a gigabyte of layers. Counts against the repo's 10 GB budget and the 7-day eviction. Requires Buildx 0.21+ and BuildKit 0.20+ since the cache service moved to API v2 in April 2025.
type=registry cache
Pushes the cache as an artifact to your container registry with mode=max support. Unlimited by Actions quotas, survives the 7-day rule, shareable across repos and even local builds. My pick for base images and anything rebuilt less than daily.
The Limits That Silently Break Your Caching
Three numbers explain almost every mysteriously slow pipeline I have debugged:
| Limit | Value | What it does to you |
|---|---|---|
| Repo cache budget | 10 GB default | Docker layer caches in mode=max can evict your dependency caches. Watch the cache list in the Actions tab. |
| Inactivity eviction | 7 days | Weekly and release-only workflows run cold forever. Consider a scheduled warmer job or registry-backed cache. |
| Key length | 512 characters | Long hashFiles expressions with many globs can overflow; hash a generated manifest file instead. |
Never cache node_modules directly keyed on the lockfile. It breaks across Node versions and OS images, shadows postinstall scripts, and saves less than caching the npm store. The package manager's own cache directory plus a clean install is both faster and correct.
Anti-Patterns I Keep Finding in Real Repos
- A single giant cache for everything. One key over node_modules, Cypress binaries, and build output means any change invalidates all of it. Split caches by change frequency.
- Caching without restore-keys. Exact-match-or-nothing turns every lockfile bump into a full cold start, which is precisely when you want a warm fallback.
- Static keys like deps-v1 that never include a hash. The cache never invalidates, builds run against stale dependencies, and the first hard-to-reproduce bug costs more than all the minutes saved.
- Caching apt packages or toolchains that a setup action already provides in seconds. The download from the cache can be slower than the install you avoided — always compare against the uncached baseline.
A 30-Minute Caching Audit for Your Pipeline
This is the sequence I run on every repo I take over:
- Open the last ten runs and note wall time per step. Anything that downloads or compiles is a caching candidate; sort by total minutes consumed, not per-run time.
- Check the Actions cache list: total size against the 10 GB budget, and whether high-value keys are getting evicted by churny ones.
- Add or fix dependency caching with setup-node (or actions/cache plus restore-keys), and verify a hit on the second run.
- Add type=gha layer caching to Docker builds with mode=max; move base or shared images to type=registry if eviction bites.
- Re-measure after a week of real traffic, not synthetic runs — cache behavior under concurrent PRs is the truth, the demo run is not.
The Takeaway
On the repo that prompted this audit, total pipeline time dropped from 11 minutes to just over 4: dependency caching with proper restore-keys saved 2 minutes, Docker layer caching with type=gha saved 4, and the Next.js build cache saved the rest. None of it required new infrastructure or paid runners — just understanding the eviction rules and putting the right cache type at each layer.
Caching is one of the rare optimizations where the work is bounded and the payoff compounds on every single push. Spend the afternoon. Your reviewers, your deploy frequency, and your Actions bill all improve together.
Sources and further reading

