Sharing Build Cache Across CI Runners

The per-repository cache built into GitHub Actions is scoped to one repo and, by default, isolated per branch. That is enough for a single linear build, but it leaves real time on the table the moment you parallelize. Run a matrix across Node versions, build ten packages in a monorepo, or spin up ephemeral self-hosted runners, and each one starts cold and redoes the same asset processing the others just finished. A shared remote cache fixes this: one runner populates a central store, and every other runner reads from it instead of rebuilding. This guide covers the architecture, the signing and trust model, and the measured savings. It is the scaling-out piece of Incremental Builds and Build Caching for SSGs.

Prerequisites

  • A build that genuinely parallelizes — a matrix, a monorepo, or multiple jobs that share inputs. If you run one job on one runner, the per-repo cache for Hugo or node_modules caching is enough.
  • A place to put the shared store: a Turborepo remote cache (hosted or self-hosted), an S3 bucket, or another object store your runners can reach.
  • Secrets management for the cache token or bucket credentials, exposed to trusted workflows only.

The Architecture

Every shared-cache scheme is content-addressed: an artifact is keyed by a hash of the inputs that produced it, so two runners that compute the same hash share the same entry. The first runner to finish a task uploads its output; later runners compute the matching hash, get a cache hit, and download instead of rebuilding.

Shared remote cache architecture across parallel runners Three parallel CI runners connect to a central remote cache store. Runner one computes a hash, misses, builds, and uploads the artifact. Runners two and three compute the same hash, hit, and download instead of rebuilding. One runner builds, the rest download Remote cache S3 / Turborepo · content-addressed Runner 1 · MISS build assets upload artifact Runner 2 · HIT same hash download artifact Runner 3 · HIT same hash download artifact upload download
Each runner computes a content hash of its inputs. The first to finish uploads the artifact; the rest get a hit on the same hash and download it instead of rebuilding.

Three Ways to Share

Turborepo remote cache

If your site lives in a Turborepo monorepo, the remote cache is the least-effort option. Turborepo already content-hashes every task's inputs; point it at a remote and the hashes become shareable across runners:

- run: npx turbo build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    TURBO_REMOTE_ONLY: 'true'   # do not write a local cache in CI

You can use the hosted remote cache or self-host an S3-backed one with an open-source cache server, keeping artifacts inside your own infrastructure.

S3-backed cache

For a non-Turborepo build, an S3 bucket plus a small step gives you the same cross-runner sharing. Compute a content hash, try to download, build on a miss, and upload:

KEY="ssg-$(cat assets/** package-lock.json | sha256sum | cut -c1-32).tar.zst"
if aws s3 cp "s3://my-build-cache/$KEY" cache.tar.zst 2>/dev/null; then
  tar --use-compress-program=unzstd -xf cache.tar.zst   # HIT
else
  npm run build                                          # MISS
  tar --use-compress-program=zstd -cf cache.tar.zst resources/_gen dist
  aws s3 cp cache.tar.zst "s3://my-build-cache/$KEY"
fi

S3 lifecycle rules expire stale entries automatically, and the bucket is reachable from any runner in any branch — exactly what the per-repo cache cannot do.

Scoped actions/cache

Without leaving GitHub-hosted infrastructure, you can still share across jobs in one workflow by using a stable primary key with shared restore-keys, so a build job writes a cache that parallel test and lint jobs restore. This is the lightest option but stays inside one repo and respects the 10 GB per-repo budget.

Signing and Trust

A shared cache is a supply-chain surface: an artifact one runner consumes was produced by another. The standard policy is trusted writes, open reads. Let only protected branches hold the credentials that write to the remote cache; give pull requests — especially from forks — read-only access. Turborepo signs cache artifacts with an HMAC key (TURBO_REMOTE_CACHE_SIGNATURE_KEY) so a consumer can verify an entry was produced by a trusted writer before using it. For an S3 store, scope the PR workflow's IAM credentials to s3:GetObject only, and keep s3:PutObject on the protected-branch workflow. This prevents a malicious fork from poisoning an artifact that main later downloads.

Trust boundary for a shared remote cache: trusted writes, open reads A trust boundary divides two workflows. On the trusted side, a protected-branch workflow (main or release) holds write and read credentials, uploads with s3:PutObject and HMAC-signs the artifact into the shared remote cache. On the untrusted side, fork and pull-request workflows hold read-only s3:GetObject credentials, download entries and verify the signature before use; their attempt to write is blocked, so a fork cannot poison an artifact that main later consumes. Trusted writes, open reads Shared remote cache content-addressed signed entries trust boundary Protected branch main / release s3:PutObject + GetObject HMAC-signs artifact TRUSTED · write + read Fork / PR workflow untrusted contributor s3:GetObject only verify signature before use UNTRUSTED · read only PUT · signs GET · verifies PUT denied
The protected branch holds the write credentials, uploads, and HMAC-signs each entry; fork and pull-request workflows get read-only access and verify the signature before use. A fork's write is denied at the boundary, so it cannot poison an artifact that main later consumes.

The same read-first, verify-before-use flow is what makes cache reuse safe in the first place: because every entry is content-addressed and, where supported, signed, a consumer trusts the hash and signature, not the runner that happened to produce it.

Measured Impact

Benchmarked on a monorepo with one Hugo docs site plus a shared design-system package, built as a 4-way matrix on ubuntu-latest. Asset processing for the docs site is ~90 s cold. Times are from the run summary, with the remote cache hosted in S3 in the same region:

SetupWall-clock for the matrixNotes
No shared cache (each job cold)4 jobs × 90 s ≈ 360 s of build workevery runner reprocesses everything
Per-repo actions/cache only~360 s on first run, ~100 s afternot shared across the matrix legs
S3 remote cache~95 s first leg + 3 × ~9 s restores ≈ 122 sone leg builds, three download

On the matrix, the remote cache turned roughly 360 s of redundant build work into about 122 s — one full build plus three fast restores — because only the first leg to reach the task actually processed assets. The restore overhead was 8-10 s per leg, dominated by download and decompression. The win scales with the width of the matrix: a wider fan-out means more legs reading one upload.

Pitfalls & Rollback

  • Unstable hashes. If the cache key includes a timestamp, commit SHA, or absolute path, every runner computes a unique key and never hits. Hash only the real inputs — source, deps, task config.
  • Untrusted writes. Letting fork pull requests write to the shared cache is a poisoning risk. Use trusted-write, open-read and verify signatures.
  • Region latency. A remote store in a different region can make downloads slower than rebuilding small artifacts. Co-locate the cache with the runners.
  • Over-caching. Pushing huge dist/ outputs into the remote cache can cost more in transfer than the rebuild saves. Cache the expensive intermediate, not everything.
  • Rollback: remove the remote-cache env vars or the S3 step and runners fall back to local or per-repo caching — slower under parallelism but fully correct, with no shared state to clean up beyond expiring the bucket entries.
Three ways to share a cache, and their trust model Three panels: the CI provider cache is simplest and scoped per repository, object storage is portable and needs credentials, and a purpose-built remote cache adds content addressing and signing. Each names who can write to it. Three ways to share a cache, and their trust model Provider cache zero setup, scoped per repo branch-scoped restore rules no cross-repo sharing writers: anyone who can run CI Object storage portable across CI systems you own retention and cost needs scoped credentials writers: whoever holds the key Remote cache service content-addressed entries signing and verification extra service to operate writers: authenticated builds only Sign entries or restrict writes to trusted branches before sharing a cache beyond one repository.
The trust question matters more than the speed one: a shared cache is an input to your build, and an input anyone can write to is a supply-chain surface.

Scope the sharing to what actually helps. Most of the benefit comes from sharing between runs of the same pipeline rather than across repositories, and that is also the configuration with the smallest trust surface.

Conclusion

A shared remote cache earns its complexity exactly when parallel runners would otherwise each redo the same work. Content-address every artifact, pick a store your runners can reach — Turborepo remote cache, S3, or scoped actions/cache — and enforce trusted-write, open-read so the cache cannot be poisoned. On a 4-way matrix it collapsed 360 s of redundant processing to about 122 s. Pair it with the per-run techniques in Enabling Incremental Builds in Eleventy and the per-repo recipe in Caching Hugo Builds in GitHub Actions for caching at every layer.

FAQ

When does a shared remote cache beat the built-in per-repo cache?

When several runners would otherwise redo the same work. A matrix build, a monorepo with many packages, or self-hosted ephemeral runners all benefit because one runner populates the cache and the others read it. A single linear pipeline on one runner is already well served by the built-in per-repo cache and gains little.

How does a remote cache decide what to reuse?

It keys each cached artifact on a hash of the inputs that produced it — source files, dependencies, and the task configuration. When another runner computes the same hash it downloads the artifact instead of rebuilding. This is content-addressed caching, so a cache entry is only ever reused when the inputs are identical.

Is it safe to share a build cache across branches and pull requests?

Reads are generally safe because entries are content-addressed, but writes from untrusted pull requests are a supply-chain risk. The common policy is to let trusted branches write to the remote cache and let pull requests read only, so a fork cannot poison artifacts that protected branches later consume.

What do I store in an S3-backed cache?

The same artifacts you would cache locally — compiled output, processed assets, a tool cache — packaged as a tarball and keyed by a content hash. A small action or CLI uploads on a miss and downloads on a hit. S3 gives you cross-runner, cross-branch storage with lifecycle rules to expire old entries.

Does a remote cache replace incremental builds?

No, they work at different layers. Incremental builds skip unchanged work within one run; a remote cache skips work that any previous run on any runner already did. Used together, a runner restores upstream artifacts from the remote cache and then does only the incremental work that remains.