Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer

CI/CD Pipelines

Build Caching for CI/CD: Docker Layers, Dependencies and Test Results

Build caching stores the results of expensive, repeatable CI work so later runs can reuse them. We cache three things: Docker image layers, package manager downloads, and test or compile results. Each uses a cache key tied to the files that determine its contents, so caches speed builds up without serving stale output.

Key takeaways

  • Caching is about correct keys first and speed second: a fast build that reuses a stale artefact is worse than a slow one.
  • Docker layer caching depends on Dockerfile order, then on a remote cache because CI runners start empty.
  • Package caches should be keyed on the lockfile and restore with a safe fallback.
  • Caches have a cost: storage limits, upload time and poisoning risk. Not every step benefits.
  • We measure cache hit rate and time saved, and remove caches that do not pay for themselves.

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_modules instead 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.

References

How we deliver it

  1. 01

    Profile where time goes

    We break a typical run into steps and find which ones are repeatable work: dependency installs, image builds, compilation, test setup.

  2. 02

    Fix the Dockerfile first

    Layer order, a tight build context and multi-stage builds make caching effective. Caching a badly ordered Dockerfile mostly caches nothing.

  3. 03

    Add dependency caches

    Package manager caches are keyed on lockfile hashes, with restore keys for partial hits.

  4. 04

    Add a remote image cache

    BuildKit cache export to a registry or the CI cache backend lets fresh runners reuse layers.

  5. 05

    Cache test and compile results

    Where the toolchain supports it, incremental compilation and test result reuse are keyed on inputs.

  6. 06

    Measure and prune

    We track hit rate, restore time and total run time, set retention rules, and drop caches that cost more than they save.

Tools and technologies

Docker BuildKitGitHub Actions cacheGitLab CI cacheAmazon ECRnpmpipMavenGradleGo modulesBazelTurborepo

Frequently asked questions

How does Docker layer caching work in CI?
Docker reuses a layer when its instruction and all inputs are unchanged. CI runners usually start with no local layers, so you need a remote cache: BuildKit can export cache to a registry or the CI cache service and import it on the next run. Instruction order matters, because one changed layer invalidates every layer after it.
What should a CI cache key be based on?
Base it on the files that determine the cached content, typically a hash of the lockfile such as package-lock.json, poetry.lock or go.sum, plus the operating system and tool version. Add restore keys so a near miss still restores most files. A key that never changes causes stale caches.
Can caching make builds wrong?
Yes. Stale or poisoned caches can produce builds that pass for the wrong reason. We key caches on real inputs, avoid caching build outputs that should be reproducible, restrict cache writes to trusted branches, and run a periodic clean build to confirm results still match.
What is the difference between a cache and an artefact?
A cache is an optimisation: it may be missing and the job must still work. An artefact is a required output passed to later jobs or kept for release. Using a cache to hand data between jobs is a common mistake, because a cache miss then breaks the pipeline.
Is caching worth it for small projects?
Often not much. If a build takes a couple of minutes and dependencies are small, caching adds key management for modest gain. It pays off when installs, image builds or compilation dominate run time, or when many developers wait on the same checks every day.

Talk to an engineer about ci/cd build caching

Book a free 30-minute call. We review your current setup and tell you honestly whether and how we can help.

Written by Saddique Khan, DevOps / SRE / Cloud Consultant Engineer. Last updated 2026-10-08.