CI pipelines that take 20 minutes are CI pipelines nobody waits on. Engineers context-switch, PRs sit idle, the feedback loop breaks. Most of that time is spent doing work that has not changed — installing dependencies, downloading Docker layers, recompiling unchanged code. Build caching is the single highest-leverage optimization for CI speed.
This post is the caches that actually matter, how to design cache keys that hit, and the failure modes that cause "caching" to silently produce wrong builds.
What to Cache
The order of impact is roughly:
- Package managers' download cache (npm, Composer, pip, Maven, Go modules)
- Docker layer cache for image builds
- Compiled artifacts (compiled TypeScript, Webpack output, JVM .class files, native binaries)
- Test runner caches (Jest cache, PHPUnit cache)
- Static analysis caches (TypeScript tsc cache, PHPStan baseline)
Get the first three right and most CI runs drop to a fraction of their cold-start time. The others compound on top.
Cache Key Design
The most common failure mode is a cache key that is too broad — it hits, but it serves stale content that does not match the actual code.
The right key is a hash of the inputs that determine the cache contents.
# GitHub Actions example
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
The key is os + node + hash(package-lock.json). If the lockfile changes, the key changes, and the cache misses. The restore-keys provide a fallback — a stale cache from the same OS but a different lockfile is better than no cache at all.
Designing keys well:
- Include the OS (different binaries on different platforms).
- Include the version of the tool (Node 18 vs Node 20 produce different node_modules).
- Include the hash of the dependency manifest, not the lockfile alone (a
package.jsonchange without a lock change should miss). - Add restore-keys so partial misses fall back to recent-but-not-exact caches.
Multi-Layer Caches
For complex builds, one cache layer is not enough. A typical Node project benefits from:
- Layer 1: npm download cache — keyed on the OS + Node version. Reusable across projects.
- Layer 2: node_modules — keyed on the package-lock.json hash. Specific to the project.
- Layer 3: build output — keyed on the source files. Specific to the commit.
Each layer is invalidated separately. A new dependency invalidates layer 2 and 3 but not layer 1. A source change invalidates only layer 3.
The pattern compounds: tools like Turborepo, Nx, and Bazel automate layered caching across many workspace packages, each with their own cache keys based on inputs.
Docker Layer Caching
For containerized builds, BuildKit's layer cache is the right tool. Two approaches:
Inline cache (cache-from inside the image itself).
- uses: docker/build-push-action@v5
with:
push: true
tags: myimage:latest
cache-from: type=registry,ref=myimage:cache
cache-to: type=registry,ref=myimage:cache,mode=max
The cache lives in the image registry. Free in disk terms, costs you push time and storage. Best when the registry is colocated with the CI runners.
External cache (S3, GitHub, or local).
cache-from: type=gha
cache-to: type=gha,mode=max
Faster than registry cache, with GitHub-specific limits on size. For larger builds, S3-backed cache is the production pattern.
The win is real: a Docker build that takes 8 minutes cold can take 30 seconds warm. The catch is that the Dockerfile has to be cache-friendly — every COPY and RUN is a cache boundary.
Cache-Friendly Dockerfiles
A Dockerfile that does not cache well negates the value of layer caching.
# Bad — copies everything before installing deps
FROM node:20
COPY . .
RUN npm ci
RUN npm run build
# Good — deps before source
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
In the good version, changes to source code do not invalidate the npm install layer. Only changes to package.json or package-lock.json invalidate it.
The general rule: things that change rarely (system packages, dependencies) before things that change often (source code).
Build Tool Caches
For incremental compilation across CI runs, the build tool's own cache is the killer feature.
Turborepo / Nx:
- uses: actions/cache@v4
with:
path: |
.turbo
node_modules/.cache/nx
key: turbo-${{ github.sha }}
restore-keys: turbo-
The build tool fingerprints each task's inputs (source files, dependencies, environment) and skips the task if the fingerprint has been seen before. Across a monorepo with 50 packages, the savings are dramatic — only changed packages and their dependents rebuild.
Bazel:
Same idea, more rigor. Bazel hashes all inputs explicitly and stores outputs in a content-addressable cache. Remote cache backends mean every CI run benefits from work done on every other CI run, across all engineers.
Go build cache, gradle build cache, Rust target/ cache:
Each language has its own incremental build cache. The CI version is to persist these caches between runs.
Pollution and Eviction
Caches accumulate junk over time. Three pollution modes to watch:
Stale entries. A cache that grows without bound eventually fills disk. Set TTLs or maximum sizes. GitHub Actions caches expire automatically; self-hosted setups need explicit eviction.
Wrong-environment data. A cache built on Ubuntu 22 used on Ubuntu 24. Architecture mismatches (x86 vs ARM). Different Node major versions. Include the environment fingerprint in the cache key.
Bad-content traps. Once, a cache was built with a corrupted dependency. Subsequent runs read the bad cache and produced wrong builds. The fix is rare but painful when it happens. Cache busting (changing the key namespace) is the manual fix.
Measuring Cache Effectiveness
If you cannot measure cache hit rate, you do not know if the cache is helping.
For most CI platforms:
- Time per job, with and without cache hits
- Cache size growth over time
- Restore-keys fallback rate (how often a perfect match misses but a partial succeeds)
A healthy setup has cache hit rates above 80% on dependency caches and 60% on build output caches. Below that, the keys are too narrow or the cache is being evicted too aggressively.
Common Failure Modes
- Cache keys that never change. A key like
node-modules-v1never invalidates. New dependencies do not get installed; old ones never leave. - Cache keys that always change. A key including
${{ github.sha }}invalidates every commit. Cache is effectively useless. - Caching node_modules with native dependencies. Cross-OS or cross-architecture restores break native modules. Either re-build natives or scope the cache to exact OS/arch.
- Caching mutable system state. Caching
/var/cache/aptworks; caching the entire OS does not. - Forgetting to cache the test result database. Jest, pytest, and others cache test results. Persisting that cache speeds up incremental test runs dramatically.
When Caching Hurts
For very small builds, the cache restore can take longer than the work being cached. Caching node_modules of 50 MB takes longer than reinstalling 5 lightweight packages.
The lesson: measure. If a job runs in under 30 seconds without cache, caching is probably not worth the complexity. The big wins are for multi-minute jobs.
A Practical Setup
For a typical Laravel + Node monorepo:
- Composer cache keyed on
composer.lockhash - npm cache keyed on
package-lock.jsonhash - node_modules cache keyed on
package-lock.jsonhash + Node major version - Docker build cache via BuildKit + GHA cache
- Turborepo cache for monorepo task graph
- PHPUnit cache for incremental test runs
This stack typically cuts CI time by 50–80% on a warm cache. The configuration is one-time work; the savings compound on every run.
Looking at CI pipelines that take twice as long as they should and starting to drag the team? We help teams design caching strategies that survive both daily use and the dependency churn that follows. scopeforged.com