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