Why caching is the cheapest speed-up in CI
A fresh CI runner knows nothing. It downloads every dependency, rebuilds every Docker layer and recompiles every file, on every push, even when the change was a typo in a README. Multiply that by dozens of developers and many pushes a day, and teams spend a large share of their engineering time and CI spend repeating identical work.
Caching removes that repetition. Done well, it is the highest return step in any CI/CD pipeline. Done badly, it creates builds that pass or fail for reasons nobody can explain. This page explains how we design caches that are fast and correct.
What a good caching strategy looks like
- Keys describe inputs. A cache entry is reused only when the files that determine its contents are unchanged.
- Misses are harmless. Every job must work with an empty cache. A cache is an optimisation, never a dependency.
- Writes are restricted. Only trusted branches write shared caches, so a pull request from a fork cannot poison them.
- Hit rate is measured. If a cache is rarely hit, it is cost without benefit.
- Retention is bounded. Old entries expire, so storage limits do not silently evict the useful ones.
Layer one: Docker image caching
Docker builds are usually the biggest single cost. The mechanism is simple: a layer is reused if its instruction and its inputs are unchanged, and a changed layer invalidates everything after it. So the fix starts in the Dockerfile.
# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Dependencies are installed from manifest files before the source is copied, so a code change does not re-download packages. A .dockerignore keeps the build context small, which matters because a changed context file can invalidate a COPY layer.
CI runners start empty, so layer reuse needs a remote cache. With BuildKit in GitHub Actions:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Registry-based cache (type=registry) is an alternative that works across CI systems and lets any runner reuse layers. The trade-off is registry storage and an extra push per build. We choose between them based on your runner setup and repository size, and it ties into our image build and scan stage.
Layer two: package manager caches
Dependency installs are repeatable and cacheable, but the key matters. Hashing the lockfile means the cache is reused exactly when dependencies are unchanged:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.lock') }}
restore-keys: |
pip-${{ runner.os }}-
The restore-keys fallback lets a lockfile change still start from a mostly warm cache, rather than from nothing. For Node, Python, Go and Java, the setup actions for each language have built-in cache options that implement the same idea with less configuration. Where they exist we use them, because fewer lines of configuration means fewer ways to get the key wrong.
In GitLab CI, the cache: keyword with key: files: serves the same purpose, and the distinction between cache and artefacts is important: use artefacts to pass required outputs between jobs, and caches only for speed.
Layer three: test and compile results
This layer has the largest ceiling and the highest risk. Incremental compilers, build tools such as Gradle and Bazel, and monorepo tools such as Turborepo can skip work whose inputs have not changed. Gradle’s build cache and Turborepo’s remote cache both work this way.
The principle is the same: results are reusable only when the full set of inputs is known and hashed. Tools designed for this (hermetic or content-addressed build systems) do it reliably. Hand-rolled “skip tests if files did not change” scripts usually do not, because they miss indirect inputs such as configuration, shared libraries or environment variables. We prefer the tool’s own cache over custom scripts, and where a team wants to skip tests for unchanged paths, we use explicit path filters and keep a full run on the main branch.
Decisions and trade-offs
Cache storage limits. Hosted CI cache services have size and retention limits. A huge cache that gets evicted before it is reused wastes upload time on every run. We size caches to what is actually reused.
Restore time versus rebuild time. Restoring a multi-gigabyte cache can take longer than rebuilding a small dependency set. We measure both before committing to a cache.
Self-hosted runners. Persistent runners can keep local caches on disk, which is fast, but cleanliness suffers: state leaks between jobs. Ephemeral runners with a remote cache are safer and only slightly slower.
Security. Caches are an attack surface. A malicious pull request that writes a poisoned entry under a trusted key can affect later builds. We scope cache writes to protected branches and keep keys specific.
When not to bother. If the pipeline takes two minutes, the biggest cost is probably something else, such as a slow test suite, which is a parallelisation problem rather than a caching one.
Common failure modes
- A key that never changes. Hard-coded keys serve outdated dependencies for months.
- Caching
node_modulesinstead of the package cache. This can hide lockfile drift and break across platforms. - Using the cache to pass data between jobs. A miss breaks the pipeline. Use artefacts.
- A badly ordered Dockerfile.
COPY . .before dependency install defeats layer reuse. - No fallback. Without restore keys every lockfile change is a cold start.
- Never running a clean build. We keep a scheduled uncached build to catch hidden dependence on cached state.
How it is delivered
We profile your current runs, fix Dockerfiles, then add caches one layer at a time, measuring after each change so you can see which ones earned their place. Configuration is committed to your repositories and, where you have several services, placed in shared workflows so every project benefits.
How to tell it is working
Track median and 95th percentile pipeline duration, cache hit rate per cache, restore and save time, and the number of builds that differ between cached and clean runs. The result should be shorter runs with no new unexplained failures. Our before and after comparisons show how we report this.
If your builds are slow and you are not sure why, talk to us.