Skip to main content
BlogDevOps

GitHub Actions Caching Strategies That Actually Cut CI Minutes

Dependency caches, Docker layer caches, and build caches each fail differently. Here are the GitHub Actions caching patterns that halved my CI times.

Matthews Wong

Software Engineer

April 20269 min read
GitHub Actions Caching Strategies That Actually Cut CI Minutes

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.

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=max

The 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:

LimitValueWhat it does to you
Repo cache budget10 GB defaultDocker layer caches in mode=max can evict your dependency caches. Watch the cache list in the Actions tab.
Inactivity eviction7 daysWeekly and release-only workflows run cold forever. Consider a scheduled warmer job or registry-backed cache.
Key length512 charactersLong 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:

  1. 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.
  2. Check the Actions cache list: total size against the 10 GB budget, and whether high-value keys are getting evicted by churny ones.
  3. Add or fix dependency caching with setup-node (or actions/cache plus restore-keys), and verify a hit on the second run.
  4. Add type=gha layer caching to Docker builds with mode=max; move base or shared images to type=registry if eviction bites.
  5. 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What are the three main cache layers that matter in a GitHub Actions pipeline?

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.

Why is the two-level restore-keys fallback important for dependency caching?

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.

When should I use type=registry instead of type=gha for Docker layer caching?

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.

What platform limits silently break naive caching setups?

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.

How much CI time can proper caching realistically save?

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.