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.
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.
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:
| Setup | Wall-clock for the matrix | Notes |
|---|---|---|
| No shared cache (each job cold) | 4 jobs × 90 s ≈ 360 s of build work | every runner reprocesses everything |
Per-repo actions/cache only | ~360 s on first run, ~100 s after | not shared across the matrix legs |
| S3 remote cache | ~95 s first leg + 3 × ~9 s restores ≈ 122 s | one 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.
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.
Related
- Parent: Incremental Builds and Build Caching for SSGs — incremental vs. caching across generators.
- Caching Hugo Builds in GitHub Actions — the per-repo recipe this scales out from.
- Enabling Incremental Builds in Eleventy — per-run incremental rebuilds.
- GitHub Actions for Automated SSG Builds — the surrounding build-and-deploy pipeline.