Containers and images
What the container-image property actually does (and doesn't), plus how to build worker images and keep what your build requests in sync with what your fleet advertises.
Who this is for: anyone whose actions need a specific environment, and anyone running more than one kind of worker image. What you'll have at the end: worker images that carry your toolchain, and a routing scheme that sends each action to the right one. Time: twenty minutes.
Before you start
A cache serving hits to your build. If you don't have one yet, start with Getting started.
What container-image is not
Start here, because the name is misleading and the misunderstanding is expensive.
NativeLink does not pull an image and launch a container for each action.
No code sits behind the container-image key. Search the source for it
and you will find it in example configs, in the LRE platform definitions under
local-remote-execution/, and in CI wiring; nothing in the scheduler or worker
source reads it. It is an ordinary string platform property with an evocative
name.
What it actually expresses is: this worker process is running inside this
image. The worker asserts a value, the build requests a value, and the
scheduler matches them like any other exact or priority property. The
environment is real; it's just established by how you started the worker,
not by anything the scheduler does per action.
Byte-for-byte or nothing
The repo's own CI action puts it plainly: the value you request "must
byte-for-byte match the container-image the claim's worker pool
advertises, or actions queue forever." No normalisation, no tag
resolution, no digest comparison. debian:bookworm-slim and
docker.io/library/debian:bookworm-slim are different strings and
therefore different pools.
That failure is the silent-queue failure from Platform properties, and it is by far the most common way a container-image scheme goes wrong.
The actual model
One image per worker pool. Every action a given worker runs shares that worker's environment. Heterogeneity comes from running several pools and routing between them, not from varying the image per action.
This has a consequence worth planning around: image churn is a fleet operation. Changing a toolchain means building a new image, rolling a pool onto it, and changing what builds request, in an order that doesn't strand actions in the queue. There's a safe sequence below.
Build a worker image
A NativeLink worker image is any image that contains your toolchain and can
run the nativelink binary with a worker config. The repo's Nix helper,
createWorker, turns a toolchain image into a nativelink-worker-* image:
nativelink-worker-lre-cc = createWorker pkgs.lre.lre-cc.image;
nativelink-worker-lre-java = createWorker lre-java;
nativelink-worker-lre-rs = createWorker pkgs.lre.lre-rs.image;
nativelink-worker-siso-chromium = createWorker siso-chromium;
nativelink-worker-toolchain-drake = createWorker toolchain-drake;
nativelink-worker-toolchain-buck2 = createWorker toolchain-buck2;What it adds on top of the base image is small: a nativelink user (uid
1000) and home directory, a world-writable /tmp, /usr/bin/env, and
coreutils, bash and gnused on /bin. It keeps the base image's tag so
the worker image and the toolchain it wraps stay visibly paired. It does
not bake the nativelink binary in, whatever its header comment says: the
Kubernetes examples inject the binary at pod start with the
nativelink-worker-init init container, which copies it into a shared
volume, and the worker container then runs /shared/nativelink /worker.json5.
That keeps the toolchain image and the NativeLink version independently
upgradable.
Push one to a registry the way the repo's own tooling does:
nix run .#nativelink-worker-lre-cc.copyTo docker://localhost:5001/nativelink-worker-lre-cc:latestYou do not have to use Nix for this. Any image that contains your toolchain
and runs nativelink with a config is a valid worker image: a Dockerfile
that starts FROM your-toolchain-base, copies in the binary, and sets the
entrypoint works identically. Nix is how this repo gets reproducible images;
it isn't a requirement of the worker protocol.
Wire up the routing
Declare the key on the scheduler
schedulers: [{ name: "MAIN_SCHEDULER", simple: { supported_platform_properties: { cpu_count: "minimum", OSFamily: "priority", "container-image": "exact", }, }, }]Use
exactonce you have more than one image. The examples shippriority, which matches any value: fine for a single-pool tutorial, useless as a routing mechanism.Advertise it on each worker
workers: [{ local: { worker_api_endpoint: { uri: "grpc://scheduler.internal:50061" }, cas_fast_slow_store: "WORKER_FAST_SLOW_STORE", upload_action_result: { ac_store: "AC_MAIN_STORE" }, work_directory: "/tmp/nativelink/work", platform_properties: { cpu_count: { query_cmd: "nproc" }, OSFamily: { values: [""] }, "container-image": { values: ["ghcr.io/example/toolchain:2026-07-01"] }, }, }, }]Pin a tag you can change deliberately.
:latestmakes the advertised string stable while the actual contents drift, which defeats the entire point: the scheduler will keep matching, and your builds will silently change behaviour.Better still, template it from the deployment so the value can't disagree with the image that's actually running:
"container-image": { values: ["${NL_WORKER_IMAGE}"] },Request it from the build
Globally, if your whole build wants one environment:
bazel build \ --remote_executor=grpc://scheduler.internal:50052 \ --remote_default_exec_properties=container-image=ghcr.io/example/toolchain:2026-07-01 \ //...Or per target, when only some actions need it:
cc_test( name = "integration_test", exec_properties = { "container-image": "ghcr.io/example/toolchain:2026-07-01", }, ... )
You did it right if
- The build reports actions as
remoterather than sitting at0s remote. - Stopping the pool that advertises the requested image makes builds queue, and starting it drains them. That's the proof the routing is real rather than everything falling through to one pool.
- With
exact, requesting a wrong image string queues indefinitely. If it succeeds instead, the key is still declaredprioritysomewhere.
Rolling an image without stranding actions
Order matters, because the two sides are matched on a literal string.
- Bring up the new pool first, advertising the new image string, while the old pool keeps running and advertising the old one. Both are now satisfiable.
- Switch what builds request. New builds land on the new pool; in-flight ones finish on the old.
- Drain and remove the old pool once nothing requests it.
Doing this in the other order (changing the request before the pool exists, or removing the pool before builds stop asking for it) produces a queue that never clears and no error anywhere.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Everything queues after an image bump | Requested string doesn't match any advertised string | Compare them character by character; registry prefixes and tags both count |
Property missing on worker property container-image | A pool doesn't advertise the key at all | Add it to that pool's platform_properties, even with an empty value |
| Routing "works" but actions land on the wrong pool | Key is declared priority, which matches any value | Change the scheduler's declaration to exact |
| Builds change behaviour with no config change | Advertised tag is mutable (:latest) | Pin an immutable tag or digest |
| Worker never joins the pool | Key not declared in supported_platform_properties | Look for Bad Property during connect_worker() in the worker log |
FAQ
Keeping a warm process between actions, for toolchains where startup cost dominates.
SidewaysRun multiple workersRunning several worker pools, each on its own image, as a real fleet.
Toolchains and hermeticity
Your action ran on a worker and failed on a missing compiler. Here is why that happens, and how to pin the toolchain so local and remote builds hash identically.
Persistent workers
Keep a JVM or Node compiler process warm across actions instead of paying its startup cost every time, and understand exactly what NativeLink gives up to do it.