Remote caching with NX

Speed up CI and local workflows with remote caching

Remote Caching on NX is provided as a NX Cloud feature. NX Cloud is amazing but might be out of reach for smaller teams that still want the benefits of NX. Remote Caching is not strictly required for NX adoption but it can help significantly with CI.

Few notes on NX caching

NX uses two caches (similar to Bazel) - a local and a remote cache. It first checks if the local cache has a match, and if the local cache has no match, it will look for a match in the remote cache. If both caches don't have matches, Nx will run the target to provide fresh outputs. You can also ask Nx to skip the cache by using --skip-nx-cache command line option.

NX computes a hash using the inputs (usually files) provided to the target and then uses that as a hash as a filename to store the output files generated by the target. Changes to nx.json, package.json, and project.json generally also invalidate the cache.

It is important to understand that a target should be idempotent and should ideally produce the same outputs if the inputs are the same. This matters especially if you are using a custom executor that is listed as a cachable target.

Enabling remote caching allows you to never have to run the same target twice if the inputs haven't changed. For example, a developer pulling down a PR branch would be able to use the build outputs stored in the remote cache by the CI job.

So how can you get started?

To use remote caching you need to set up a new runner. Modifying a runner is generally something that wouldn't be recommended and is not documented. One reason for that is that when you eventually move to NX Cloud your custom runner becomes unusable. However, if we restrict ourselves to just remote caching this should be "safe" since NX Cloud will take care of remote caching.

We can start by using this excellent package by @niklaspor: https://github.com/NiklasPor/nx-remotecache-custom

All we need to do is implement the createCustomRunner method - below is an example for GCS.

import { Readable } from "stream";
import { Storage } from "@google-cloud/storage";
import {
  RemoteCacheImplementation,
  createCustomRunner,
  initEnv,
} from "nx-remotecache-custom";

export default createCustomRunner<any>(
  async (options): Promise<RemoteCacheImplementation> => {
    initEnv(options);

    const gcsBucket = new Storage().bucket("my-gcs-bucket-name");

    return {
      name: "NX GCS Remote Cache",
      fileExists: async (filename: string): Promise<boolean> => {
        const [exists] = await gcsBucket.file(filename).exists();
        return exists;
      },
      retrieveFile: async (filename) =>
        Readable.from((await gcsBucket.file(filename).download(options))[0]),
      storeFile: async (filename: string, stream: Readable): Promise<void> => {
        const buffers = [];
        for await (const data of stream) {
          buffers.push(data);
        }

        await gcsBucket.file(filename).save(Buffer.concat(buffers));
      },
    };
  }
);

After this is done we simply need to import this as a dev dependency for root package.json and set it up as the default runner in nx.json. In the below example, I am assuming you published the above to a private registry called my-private-registry with a package name of nx-gcs-remote-cache .

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "@my-private-registry/nx-gcs-remote-cache",
      "options": {
        ...

Additionally, you may want to define cachable targets now that you have caching. In another article, we'll go into fine-tuning inputs to get the caching behavior you want.