InsightsDevOps
Making CI fast with caching that stays correct
How to cache dependencies and Docker layers in GitHub Actions, design cache keys that invalidate at the right time, and avoid caches that slow builds down or break them.
Published September 24, 20245 min read
A slow pipeline costs more than runner minutes. Engineers batch changes to avoid waiting, context-switch while builds run, and stop trusting a check that takes twenty minutes to report a typo. Most of that time is usually spent doing the same work as the previous run: downloading the same packages, compiling the same dependencies, rebuilding the same image layers.
Caching removes the repeated work. Done carelessly, it also produces builds that pass in CI and fail on a clean machine, or caches so large that restoring them takes longer than rebuilding. This article covers the parts that matter in GitHub Actions.
Measure before caching
Look at the timing of each step in a few recent runs. The usual candidates are:
- Dependency installation (
npm ci,pip install,go mod download, Maven or Gradle resolution). - Compilation of dependencies that rarely change.
- Docker image builds where only the last few layers depend on the code that changed.
If a step takes ten seconds, caching it will save little and adds another thing that can go wrong. Start with the steps that take minutes.
Use the cache built into setup actions
For most languages, the official setup actions already handle dependency caching. One input replaces a hand-written cache step:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm testactions/setup-python@v5 accepts cache: pip (also pipenv and poetry), and actions/setup-java@v4 accepts cache: maven or cache: gradle. actions/setup-go@v5 caches the module and build cache by default. These actions key the cache on a hash of the lock file and restore it automatically.
Note what is cached: the package manager's download cache, such as ~/.npm, not node_modules. npm ci deletes node_modules before installing, so caching it would gain nothing. The download cache still saves the network transfer, and the install itself stays clean and reproducible.
Cache keys with actions/cache
For anything the setup actions do not cover, use actions/cache directly. The key decides when the cache is reused and when it is rebuilt:
- uses: actions/cache@v4
with:
path: |
~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: |
pre-commit-${{ runner.os }}-Good keys follow a few rules:
- Include the operating system and architecture. Native modules built on Linux will not work on macOS, and caches built on x64 should not be restored on ARM runners.
- Include a hash of every file that defines the content. Usually that is the lock file. Use
hashFileswith a glob if there are several, such as**/go.sum. - Include tool versions that affect the output, such as the language version, when they are not already captured by the lock file.
- Use
restore-keysfor partial matches. When the lock file changes, an older cache with the same prefix is restored, and only the difference is downloaded.
Cache entries in GitHub Actions are immutable. When a run gets an exact key match, the cache is not saved again at the end of the job, even if the directory changed. When the key misses, the job saves a new entry under the new key. That is why the key must change whenever the content should.
How GitHub scopes and evicts caches
Two platform rules shape what works:
- Scope by branch. A workflow run can restore caches created on its own branch and on the base branch, usually the default branch. Caches from one feature branch are not visible to another. To make caches useful for pull requests, make sure the workflow also runs on pushes to the default branch, so a fresh cache exists there.
- Size limit and eviction. Each repository has 10 GB of cache storage. Entries not accessed for seven days are removed, and when the limit is reached the least recently used entries are evicted first. A few oversized caches can push out everything else.
The Actions cache page in the repository settings, and gh cache list, show what is stored and how large it is.
Docker layer caching with buildx
Docker builds benefit from caching more than any other step, but ephemeral runners start with an empty layer cache. BuildKit can export its cache to an external backend and import it on the next run. With docker/build-push-action:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: registry.internal/checkout/orders:${{ github.sha }}
cache-from: type=gha,scope=orders
cache-to: type=gha,mode=max,scope=orderstype=ghastores layers in the GitHub Actions cache, within the same 10 GB limit.mode=maxexports layers from every build stage, not only the final image. For multi-stage builds, that is where most of the value is, because the dependency stage is cached.scopeseparates caches when one repository builds several images, so they do not overwrite each other.
For large images, or to share a cache across repositories, use the registry backend instead: cache-to: type=registry,ref=registry.internal/checkout/orders:buildcache,mode=max. It has no 10 GB ceiling and works the same on self-hosted runners.
None of this helps if the Dockerfile invalidates the cache early. Copy the dependency manifest and install dependencies before copying the source code, so a code change only rebuilds the final layers:
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]What not to cache
Some caches cost more than they save, or cause failures that are hard to trace:
| Do not cache | Why |
|---|---|
node_modules and virtualenvs keyed loosely | Platform-specific binaries and stale packages leak between runs |
| Build outputs that tests should rebuild | A test run against yesterday's artifact proves nothing |
| Anything containing credentials | Caches are readable by any workflow run on the branch and its base |
| Very large directories that change on every run | Upload and restore time exceeds the time saved |
Anything written by jobs that run untrusted code in the default branch's scope, such as pull_request_target | A poisoned cache can affect trusted builds |
When a build fails in a way that might be cache-related, the quickest test is to change the key prefix, for instance from v1- to v2-, and rerun. If the failure disappears, the cache was the problem.
Other ways to save time
Caching is one tool. A few others often matter as much:
- Run independent jobs in parallel instead of one long sequential job.
- Skip work that a change cannot affect, using
pathsfilters on workflow triggers. - Split slow test suites across a matrix of runners.
- Use larger runners for compile-heavy jobs, where the time saved often outweighs the cost.
Where to start
- Find the three slowest steps across your most frequently run workflows.
- Turn on the built-in
cacheinput of each setup action. - Add buildx with
cache-fromandcache-toto every Docker build, and fix Dockerfile layer order. - Run the workflow on pushes to the default branch so pull requests have a cache to restore.
- Check cache usage after a week, and remove entries that are large and rarely hit.
- Compare median pipeline duration before and after, so you know which changes paid off.