Documentation

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 environment block.
  • Jenkins - bind the credential with withCredentials and 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.

Put the cache in your pipeline
One secret, two environment variables, read-only for pull requests.
Start freeSee pricing