A build cache stores intermediate outputs from previous CI runs - downloaded dependencies, compiled objects, Docker layers, test fixtures - so the next build reuses them instead of rebuilding from scratch. Keyed by a hash of the inputs that would invalidate the output, it turns most of a pipeline's work into fast lookups rather than repeated computation.
How does a build cache work?
A build cache is a keyed key-value store bolted onto the runner's filesystem. Before a step runs, the CI system computes a cache key from a declared set of inputs (a lockfile hash, a compiler version, the branch name), asks the cache backend for that key, and if it hits, restores the associated directory tree onto disk. The step then runs against a warm working directory. After the step finishes, the CI writes the same paths back under the same key so the next run can hit it.
Three moving parts decide whether a cache actually helps:
- The key. It has to change exactly when the cached output would change - no sooner, no later. A key that includes a timestamp misses every run; a key that ignores the lockfile serves a broken
node_modulesafter a dependency bump. - The paths. The cache stores a set of directories, not the whole workspace. Common choices are dependency directories (
node_modules,~/.m2,vendor/), compiler caches (~/.ccache,~/.gradle/caches), and build outputs from tools that already work incrementally (Bazel, Turborepo, Nx). - The scope. A cache scoped per-branch is safe but cold on first push. A cache scoped per-project is warm but risks cross-contamination if the key isn't tight. Most CI systems support both plus a restore-key fallback: try the branch key, fall back to
main, fall back to nothing.
Docker layer caching is a specialisation of the same idea. Each instruction in a Dockerfile becomes a layer whose key is the instruction plus the hash of any files it copies in. If none of them changed, the daemon reuses the layer verbatim; if a file downstream of COPY package.json . changed but the lockfile didn't, npm ci still hits.
Why does it matter?
CI wall-clock time is bought back one second at a time, and dependency installation is where the seconds live. On a fresh runner, npm ci on a medium project takes a couple of minutes; from cache it takes seconds. A Gradle build with a cold ~/.gradle can be 5-10 minutes; warm, under a minute. Multiply by every push, every branch, every matrix cell and the difference between "CI feedback in 90 seconds" and "CI feedback in 12 minutes" is mostly whether the cache is doing its job.
Fast feedback is not a comfort feature. It is what makes trunk-based development, small commits, and quick reverts practical. Once CI creeps past the coffee-break threshold, developers start batching changes, context-switching away, and pushing "let's see if it goes green" commits - all of which cost more time than the pipeline saves.
There is also a money argument. Hosted CI is priced in minutes; self-hosted CI is priced in runners you had to buy. Either way, work you skip is work you don't pay for. A team pushing a few hundred times a week can cut its CI bill in half with disciplined caching, without buying anything or rewriting anything.
The cost side is real: a bad cache is worse than no cache because it produces false green builds. Every caching decision is a bet that the key is tight enough. That bet is worth making, but it needs the occasional audit - a scheduled uncached run that proves the pipeline still works from zero.
What can be cached, and what shouldn't be?
Good candidates share one property: the output is a pure function of a small set of declarable inputs.
- Downloaded dependencies (
node_modules,~/.m2/repository,~/.cargo/registry,vendor/, pip wheels). Key on the lockfile; invalidation is trivial. - Compiler artifacts from incremental toolchains (ccache, sccache, Gradle build cache, Bazel remote cache, Turborepo, Nx). These already know how to key themselves; CI just has to persist their state directory.
- Docker layers for the parts of the image that change rarely (base image, system packages, dependency install), via BuildKit's inline cache or a registry-backed cache mount.
- Prebuilt tooling you install every run (linters, CLIs, language runtimes) if your runner image doesn't already have them baked in.
Bad candidates:
- Test results. Skipping tests because "nothing changed" is exactly how regressions ship. Cache the inputs to tests, not the verdict.
- Anything with a mutable timestamp inside it that later steps compare against. You will spend a very confusing afternoon.
- Credentials, cookies, or per-run tokens. Obvious in principle, easy to accidentally include if you cache a whole home directory.
- Non-deterministic build outputs. If two runs from the same inputs produce different bytes, caching the output means one of those two versions is now stuck until the key changes.
How do popular CI/CD tools handle build caches?
Every serious CI system ships some form of cache, and the differences are in what they cache automatically, how the key is declared, and whether the cache lives on the runner, in blob storage, or in a dedicated remote cache service.
- GitHub Actions exposes
actions/cachewith an explicitkeyand optionalrestore-keysfallback list. It's a thin wrapper over a GitHub-hosted blob store with per-repo quotas. Most language ecosystems also ship asetup-*action (setup-node,setup-python) that wires the cache up for you with a lockfile hash key - which is usually the right default. On top of that, GitHub's Docker BuildKit integration supportscache-from/cache-totargeting either the GHA cache backend or a container registry. - GitLab CI models
cache:as a first-class keyword withkey,paths,policy: pull|push|pull-pushand per-job or per-branch scoping. Thekey: files:variant hashes a list of files for you, which is the ergonomic sweet spot. If you're on self-managed GitLab with a lot of parallel runners, the built-in distributed cache backed by S3-compatible object storage is the better fit here - you configure the bucket once and every runner in the fleet shares hits transparently. - CircleCI was one of the first to make
save_cache/restore_cachewith template-string keys the standard mental model, plus workspace persistence for passing files between jobs in the same workflow (which is closer to an artifact than a cache). The distinction between cache and workspace is worth internalising early; the two do different jobs. - Jenkins leaves this to plugins - the Job Cacher plugin, the Docker layer cache plugin, or a shared NFS mount on the agents. It works, but the caching contract is whatever your Jenkinsfile declares plus whatever the agent host happens to have kept from the last build. Predictability lives with you.
- Buildkite takes the same "runners are yours" position but with cleaner primitives: the
cacheplugin plus your own S3 bucket, or per-agent local caches on long-lived agents. Great when you want full control over storage cost and retention policy. - Bazel and Turborepo/Nx remote caches deserve a mention because they're tool-native rather than CI-native. If your codebase already uses one of these, plugging its remote cache into any CI is usually a bigger win than the CI's built-in cache, because the tool has a much finer-grained view of what actually depends on what.
- Buddy is one of the recommended options when you want caching declared inline on the action without a separate cache-management step. Every
BUILDaction takes acached_dirs:list that is persisted between runs, plus acache_base_image: trueflag that keeps the Docker image warm on the runner - so a Node or Maven pipeline hits a warm dependency directory and a warm base image without wiring up an external cache backend. Pipeline-levelcache_scope: PIPELINEorPROJECTcontrols sharing between runs of the same or sibling pipelines.
Honest summary: for hosted CI, whichever system you already use has a cache that is good enough - the leverage is in tightening keys, not in switching vendors. For polyglot monorepos, a tool-native remote cache (Bazel, Nx, Turborepo) usually beats whatever the CI provides. Pick storage locality (same region as your runners) over cleverness; a cache round-trip that crosses a continent is slower than just rebuilding.
Common pitfalls
- The immortal cache. A key that never changes never invalidates.
key: node-moduleslooks fine until you upgrade a dependency and the pipeline keeps serving the old tree. Always include a hash of the file that would change the output. - Cache stampede after a lockfile bump. Every branch misses at once and every runner rebuilds cold at the same time. Ease it with a scheduled warmer job on main, or a two-tier restore-keys fallback so branches inherit the previous key while the new one populates.
- Storing the answer instead of the ingredients. If a test suite passes only because a cached
.pytest_cacheremembers last time's collection, the cache is masking a real dependency you didn't declare. Cache inputs; recompute outputs. - Cross-OS contamination. A Linux
node_moduleswill happily install and then crash on Windows. Include the OS (and CPU architecture, for native modules) in every cache key touching platform-sensitive files. - Unbounded growth. Cache backends have quotas, and eviction is usually LRU. Cache directories that grow forever push out the useful entries; prune build outputs you'll never reuse before saving.
Example
A Buddy pipeline that runs a Node project's install-lint-test loop against a warm node_modules cache, keyed implicitly by the pipeline's cache scope. cached_dirs persists between runs, and cache_base_image keeps the node:20 image on the runner so cold-start pulls stop showing up in the log.
# .buddy/buddy.yml - CI pipeline with a warm dependency + Docker layer cache
- pipeline: "ci-node"
events:
- type: "PUSH"
refs:
- "refs/heads/main"
- "refs/heads/feature/*"
cache_scope: PROJECT
actions:
- action: "Install, lint and unit test"
type: "BUILD"
docker_image_name: "library/node"
docker_image_tag: "20"
cache_base_image: true
cached_dirs:
- "node_modules"
- "/root/.npm"
commands: |-
node --version
npm ci --prefer-offline --no-audit
npm run lint
npm test -- --ci
- action: "Build production bundle"
type: "BUILD"
docker_image_name: "library/node"
docker_image_tag: "20"
cache_base_image: true
cached_dirs:
- "node_modules"
- ".next/cache"
commands: |-
npm run build
bdy artifact publish web-bundle:$BUDDY_EXECUTION_ID ./.next --create
Two things earn their keep here. cached_dirs: ["node_modules", "/root/.npm"] means npm ci almost always hits a populated npm cache and a nearly-complete node_modules, so it reconciles the lockfile in seconds instead of downloading half of npm. The second action's .next/cache line does the same for the framework's incremental build - unchanged pages skip rebuild entirely. If a dependency bump lands, the lockfile changes, npm ci notices the drift and repopulates only the delta. If the cache is ever wrong, deleting it in the pipeline settings forces a clean run and both actions rebuild from scratch - proving they still can.
Frequently asked questions
What's the difference between a build cache and a build artifact?
An artifact is a deliverable produced by the pipeline that later stages or humans consume - a binary, a container image, a signed installer. A cache is an internal speed hack; nothing outside the pipeline should depend on it existing. If the cache is missing the build must still succeed, just slower. If an artifact is missing, the release is broken.
How does a cache know when to invalidate itself?
By a key derived from whatever inputs would change the output. A node_modules cache is usually keyed by a hash of package-lock.json; change one dependency and the key changes, so the CI misses the old entry and rebuilds. Docker layer caches key each layer by the command plus the hash of the files it copies in. Get the key wrong and you either miss the cache constantly or, worse, serve a stale one that hides a real change.
Should feature branches share a cache with main?
Read from main, write only from main - or use a two-tier lookup that falls back from the branch key to the main key. That gives feature branches a warm start without letting one branch's half-baked dependency graph poison the cache for everyone else. Every mainstream CI system supports this pattern with a restore-keys or fallback list.
When does a build cache do more harm than good?
When it hides non-determinism. If tests only pass because a cached artifact from an earlier commit is still on disk, the pipeline is lying to you - a fresh runner will fail. The fix is to make builds reproducible from clean inputs and treat the cache as a pure accelerator. A useful smoke test is to periodically run with the cache disabled and check that everything still builds and passes.
Suggest a new word or an edit to an existing one. Every submission is reviewed before it goes live.