CI setup for GitHub Actions and more
CI is where a remote cache earns its keep: a clean runner has no local cache at all, so every task it can download is a task it does not run. The wiring is the same everywhere - put the token in your secret store and expose it to the build.
CI setup (GitHub Actions)
Store the token as a repository secret (e.g. CACHELY_TOKEN) and expose the variables at the workflow level - every nx invocation in the job then picks them up automatically:
# .github/workflows/ci.yml
env:
NX_SELF_HOSTED_REMOTE_CACHE_SERVER: https://remote.cachely.dev
NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: ${{ secrets.CACHELY_TOKEN }}Setting them at the workflow or job level rather than per step is deliberate: a variable declared on one step is invisible to the next, which is the usual reason a pipeline caches during build and misses during test.
Give pull requests a read-only token
Give pull_request workflows a read-only token from a separate secret, so untrusted branches can use the cache but never write to it. That is what closes the CVE-2025-36852 cache-poisoning attack: a fork build that can write to your cache can publish an artifact your protected branch later replays.
Select the token by which workflow runs, not with an inline expression:
# .github/workflows/ci.yml - trusted branches, read-write token
on:
push:
branches: [main]
env:
NX_SELF_HOSTED_REMOTE_CACHE_SERVER: https://remote.cachely.dev
NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: ${{ secrets.CACHELY_TOKEN }}# .github/workflows/pr.yml - pull requests, read-only token
on: pull_request
env:
NX_SELF_HOSTED_REMOTE_CACHE_SERVER: https://remote.cachely.dev
NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: ${{ secrets.CACHELY_TOKEN_READONLY }}Resist the tempting one-liner here. The ${{ cond && secrets.READONLY || secrets.WRITE }} idiom looks equivalent and is not: an undefined secret evaluates to the empty string, which is falsy, so a mistyped or not-yet-created CACHELY_TOKEN_READONLY makes the expression fall through and hand pull requests the read-write token. It fails open, and it fails silently. Two workflows fail closed: a missing read-only secret means an empty token, which is rejected with a 401 and costs you cache hits rather than your cache's integrity.
The scope is enforced server-side, so this is a real boundary rather than a convention - see tokens and access control.
Other CI providers
There is nothing GitHub-specific about any of it. Any provider works the same way: read the token from your secret store and export the variables your build tool expects, before the build runs.
- GitLab CI - set the variables under
variables:in.gitlab-ci.yml, with the token as a masked, protected CI/CD variable. - CircleCI - add the token to a context or project environment variable; reference it in the job's
environmentblock. - Jenkins - bind the credential with
withCredentialsand export the variables inside that block so the token never lands in the build log. - Buildkite, Woodpecker, Drone, self-hosted runners - same pattern: secret in, environment variable out.
For Gradle export GRADLE_CACHE_TOKEN instead, and for Bazel pass --remote_header="Authorization=Bearer $CACHELY_TOKEN" on the command line rather than committing a user.bazelrc.
Confirm CI is actually hitting the cache
Two green pipelines prove nothing on their own. Compare a run before and after on wall-clock time, and check the workspace insights in the Cachely dashboard: cache operations should appear against the CI token specifically. An empty dashboard after a full pipeline means the variables never reached the build - see troubleshooting. For CI cache strategy more broadly - actions/cache versus artifacts versus a task-level cache - see the GitHub Actions cache guide.