GitHub Actions Caching Strategies That Actually Cut CI Minutes

Photo by Roman Synkevych
The three layers are package manager dependency caches, Docker image layer caches, and framework build caches. Each layer fails differently and benefits from a different caching approach. Addressing all three together is what drives the largest reductions in CI wall time.
Without restore-keys, any lockfile change produces a full cold install because there is no fallback when the exact key misses. With the two-level fallback, a lockfile bump still restores the previous cache and only downloads the delta. On a mid-size Next.js app this is the difference between a 40-second and a 3-minute install step.
type=gha stores layer blobs in the same 10 GB repository cache budget and is subject to 7-day inactivity eviction, making it ideal for app images under roughly a gigabyte rebuilt daily. type=registry pushes the cache to your container registry, is unlimited by Actions quotas, survives the 7-day rule, and can be shared across repos and local builds — the better pick for base images or anything rebuilt less than daily.
Three limits cause most mysteriously slow pipelines: the 10 GB per-repository cache budget (Docker layer caches in mode=max can evict dependency caches), 7-day inactivity eviction (weekly or release-only workflows effectively never have a warm cache), and a 512-character key length limit (long hashFiles glob expressions can overflow). Never cache node_modules directly keyed on the lockfile — it breaks across Node versions and OS images and saves less than caching the npm store.
Most teams can cut 30 to 60 percent of their CI wall time with an afternoon of work. On the repo that prompted the audit described in the post, 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 minutes, and the Next.js build cache saved the rest — all without new infrastructure or paid runners.

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