GitHub Actions Caching Strategies That Actually Cut CI Minutes

Photo by Roman Synkevych

Photo by Roman Synkevych
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.
Before optimizing anything, internalize the platform rules, because they shape every strategy decision:
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.
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.
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.
This is the sequence I run on every repo I take over:
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