NativeLink

Your first remote action

Run a scheduler and a worker on your own machine, point Bazel at them, and watch a build action execute somewhere other than where you typed the command.

Who this is for: anyone with a working cache who wants to see remote execution actually run. What you'll have at the end: a build whose actions Bazel reports as remote, running against a scheduler and worker you started yourself. Time: about fifteen minutes.

Before you start

A cache serving hits to your build. If you don't have one yet, start with Getting started.

The goal here is deliberately small: prove the protocol works before you touch anything else. Everything runs on your machine, in one process, using the toolchain already on your PATH. No containers to build, no fleet to provision, no hermeticity questions yet; those come next, and they're much easier to debug once you know the wiring is sound.

What you need

A Bazel workspace you can build (any one will do; the NativeLink repo itself works), and either Docker or Nix. Up to about 4 GB of free disk under /tmp for the caches this creates (the three filesystem stores in the config cap at 2 GB, 200 MB, and 2 GB).

Get the config

The repo ships a config that is exactly this tutorial: CAS, action cache, scheduler, and one worker, all in a single process on localhost.

curl -O https://raw.githubusercontent.com/TraceMachina/nativelink/refs/tags/v1.6.5/nativelink-config/examples/local_rbe_self_test.json5
local_rbe_self_test.json5

It is worth reading before you run it: it is short, and every section of it maps onto something you already know or are about to learn.

  1. Start the cluster

    nix run github:TraceMachina/nativelink ./local_rbe_self_test.json5

    Or, if you'd rather use the container image, mount the config in and publish both ports:

    docker run \
      -v $(pwd)/local_rbe_self_test.json5:/config \
      -p 50051:50051 -p 50061:50061 \
      ghcr.io/tracemachina/nativelink:v1.6.5 config

    Leave it running. You'll want its output visible; the scheduler logs here are the primary diagnostic for the rest of this page.

  2. Confirm the worker registered

    The worker connects outward to the scheduler on 50061 a moment after startup. In the server's log you should see it announce itself before you run anything, at the default INFO level:

    Worker registered with scheduler worker_id=...
    LocalWorker::run

    If instead the log repeats Error with a connection failure every half second, the worker cannot reach the worker_api listener; check that the servers block in the config you downloaded still has the 50061 listener. NativeLink does not serve gRPC reflection, so grpcurl ... list is not a way to check which services are up; the config file is.

  3. Point a build at it

    From any Bazel workspace:

    bazel test \
      --remote_cache=grpc://127.0.0.1:50051 \
      --remote_executor=grpc://127.0.0.1:50051 \
      --remote_default_exec_properties=cpu_count=1 \
      //your:target

    Both flags point at the same port here, because this config puts the cache and the execution service on the same listener. In a real deployment they're usually different processes and different ports; the flags don't change.

    --remote_default_exec_properties=cpu_count=1 is worth passing even though this config would accept an action without it. The scheduler here declares cpu_count as a minimum property and the worker advertises exactly 1; an action that asks for nothing matches any worker and consumes none of that capacity, which happens to work here, but declaring it explicitly is the habit that will save you on the next page.

  4. Read the result

    Ask Bazel where each action ran rather than trusting the wall-clock number:

    bazel test \
      --remote_cache=grpc://127.0.0.1:50051 \
      --remote_executor=grpc://127.0.0.1:50051 \
      --remote_default_exec_properties=cpu_count=1 \
      --nocache_test_results \
      --build_event_json_file=/tmp/bep.json \
      //your:target
    
    jq -r 'select(.id.testResult) | .testResult.executionInfo.strategy' /tmp/bep.json
    remote

    That single word is the whole tutorial. --nocache_test_results forces the action to actually execute instead of being served from the action cache, which is what you want the first time; otherwise a cache hit will look like a success and tell you nothing about whether execution works.

You did it right if

  • The server's log shows Worker registered with scheduler before you run the build.
  • The build event log reports strategy as remote, not local or worker.
  • The server's log shows Executing command from the in-process worker, so the action left the queue rather than sitting in it.
  • Running the same build again without --nocache_test_results completes near-instantly: the worker's output went into your action cache, so the second run is a cache hit.

That last one is the point of the whole reading path in miniature: remote execution doesn't replace your cache, it fills it. Every action a worker completes becomes a hit for whoever asks next.

What just happened

Four processes' worth of work happened inside one:

  1. Bazel uploaded the action's inputs to the CAS and asked the execution service to run it.
  2. The execution service handed the action to the scheduler, which compared what the action asked for (cpu_count=1) against what the registered worker advertised (cpu_count: 1) and found a match.
  3. The worker fetched the inputs from the CAS by digest, hardlinked them into a sandbox under its work_directory, and ran the command.
  4. The worker uploaded the outputs back to the CAS and the result to the action cache, and the scheduler streamed completion back to Bazel.

Everything after this is that same loop with more machines in it.

When it doesn't work

FAQ

NextPlatform properties

How the scheduler decides which worker gets an action, and why a mismatch queues forever instead of erroring.

SidewaysToolchains and hermeticity

If your action ran but failed on a missing compiler, this is the page you want.

On this page