Pair your own runners

Goal: your builds run on machines you control. saggar.dev queues the jobs and serves the artifacts; a runner is the machine that does the building. A home PC, a workstation or a fleet all work. A runner needs no inbound port and holds no store credentials: it polls for jobs and streams everything through the service.

1. Prepare the machine

A runner needs:

  • unprivileged user namespaces with subuid/subgid entries for the service user (image-environment builds),

  • podman, the builder for docker-format trees,

  • binfmt/qemu for foreign-arch targets.

There is no capability matrix to register. A machine missing what a job needs fails the job, naming the missing tool. Pre-flight the machine before pairing:

$ saggar-worker doctor

It probes user namespaces by actually creating one, checks newuidmap/newgidmap and the subuid/subgid entries, reports the builders (podman required when the configured builder is podman; docker informational) and the data dir (writable, free space). When the machine’s [network] configures a registry_mirror, the mirror is probed too: one short-timeout HTTP GET, informational. A mirror down at doctor time is a warning about the mirror, not a failing machine. Non-zero exit when a required piece is missing.

2. Pair the runner

Pairing names an account. The runner joins as the account that owns the presenting token. On a machine where saggar login has run, the stored token pairs it:

$ saggar-worker pair

The pair subcommand pairs and exits; the next plain start claims jobs. Anywhere else, pass an account token:

$ saggar-worker --account-token sgt-…

The pairing is admitted inline; the token proves the account, so approving is pairing. The runner appears on your account’s Fleet page in the web app. The issued runner token persists under the data dir (0600) and is reused from there on; the account token is only consulted on first start. A dead account token (401/403) stops the worker with a clear error; transport errors retry. On approval the worker prints a ready-to-paste systemd user unit (its ExecStart re-runs the flags the worker was started with; no credential ever lands in it, the unit leans on the persisted token) plus the systemctl --user enable --now and loginctl enable-linger steps, so a paired runner becomes a service in one paste.

Infra-as-code instead: --token (env SAGGAR_WORKER_TOKEN) presents a pre-provisioned runner token and skips pairing entirely.

The service address defaults to https://api.saggar.dev; for another instance, pass --hub. The runner also takes --config and --data-dir, and runs one build at a time.

Declare the conduct profile

Pass the machine’s posture on the same command line. The service records what the owner declares, never probes, and shows it to anyone considering sharing with the runner:

--network online|pinned|offline

The schedulable one. offline declares no outbound network: the service only offers the runner jobs whose source is an uploaded tarball (the service streams the tree itself); a git-source job waits for a network-capable runner. pinned declares egress through mirrors: the pull-side pins live in the runner’s [network] config, where registry_mirror routes docker-format base-image pulls through a pull-through cache (see Configuring a runner). Scheduling treats pinned like online: the declaration is about how the machine reaches the network, not whether it can.

--no-docker-format

Declares the machine cannot build docker-format trees at all (no podman builder installed); the service stops offering it docker-format jobs. Default: it can.

A re-pair re-declares; a body that omits the fields pairs with the safe defaults. Host execution stays off. A build that cannot resolve to a container environment fails at bootstrap, naming the gap.

3. Watch the fleet

$ saggar node ls

The roster shows the runners you own and the runners shared with you: owner and owned flag on every row, next to the declared profile.

Share a runner (as its owner) through the API or the web app:

$ curl -X PUT -H "X-Auth-Token: sgt-…" -d '{"handle": "ada"}' \
    https://saggar.dev/api/v1/nodes/build-01/shares

Sharing grants use: a shared account’s jobs may schedule on the runner, eligibility re-evaluated per claim. Revoking a share (DELETE …/shares/ada) stops future claims instantly while a running job finishes its lease. Retire a runner you own with saggar node retire build-01 (its token dies immediately; the row stays listed retired).

A job no eligible runner can take (wrong arch, a git source with only offline runners, docker format with no capable runner) stays queued, visible in the jobs list.

Configuration

The runner reads a saggar.toml, and only the build-side sections mean anything on it: data_dir, [envs], [incremental], [network], [atlas] and the [plugins] knobs. They are documented in Configuring a runner.

A [storage] section on a runner is ignored with a loud warning: every artifact byte streams through the service under the job’s lease (see The runner protocol), and store credentials do not belong on a build machine.