Runners
A runner is whatever executes a job. Two kinds are yours to use: the hosted
pool, which is the default and needs no setup, and self-hosted runners — the
neo CLI acting as an agent on your own machine, either standing by for work
(neo runner start) or for one push at a time (neo push --runner).
Hosted runners
Section titled “Hosted runners”Hosted runners are neosource’s own pool, and they are what runs your jobs. You do not configure or provision anything: push a workflow and jobs are scheduled onto the pool.
Every job runs in the same canonical environment, with tools added by
packages:. See The job environment — that page is the
one that determines what your job can actually do.
How a hosted job runs
Section titled “How a hosted job runs”Each hosted job runs in its own Cloud Hypervisor virtual machine on a runner
node, and the VM is discarded when the job ends. It is sized by the job’s
size::
size: |
vCPUs | Memory |
|---|---|---|
small (default) |
2 | 6 GiB |
medium |
4 | 12 GiB |
large |
8 | 24 GiB |
mega |
16 | 24 GiB |
A node runs several VMs at once and reports its free capacity on every poll.
The scheduler places a job by best fit and prefers a node that already holds a
warm cache disk for that job. A VM boots fast: for a job with no services:
and no Nix environment, claim to first step is typically under a second. If a
VM ever dies without reporting an outcome, the job is automatically requeued
once.
Each VM runs its own rootful dockerd (with buildx and compose), which is
also what runs services:.
Each repository’s workflow jobs (every matrix leg on its own) get a cache
disk that persists between runs: Cargo’s build output ($CARGO_TARGET_DIR),
~/.cache, Cargo’s registry and git caches, Go’s module cache, the npm, bun,
pnpm and Yarn download caches, and Docker’s images and build cache. The
checkout, node_modules and the Nix store are not on it. Every job starts
from a private copy-on-write fork of the last committed disk. Only a trusted
run on a protected branch, triggered by a push, a schedule or
workflow_dispatch, commits its copy as the next generation; a pull request
(including one from a fork) or merge_group run reads the cache but never
writes it back.
The job’s VM starts with an empty Nix store, so a packages: or flake:
environment is downloaded in every job today. What that costs, what is
planned, and which part of a job is billed:
Setup time, caching and billing.
What runs-on selects
Section titled “What runs-on selects”This is the most common misunderstanding coming from GitHub Actions.
runs-on is a scheduling label. It does not choose an environment. There is
no runs-on value that gives your job a different base image or a different
operating system.
jobs: a: runs-on: ubuntu-latest # a label packages: [nodejs_26] # this is what determines the environment
b: runs-on: [self-hosted, linux] # a different place packages: [nodejs_26] # same tools either waypackages: and flake: are job-level keys honoured on every runs-on; the
only rule is that a job may not declare both. runs-on is optional and
defaults to ubuntu-latest.
A job that declares no packages: and no flake: runs in the base Ubuntu
24.04 environment (with the ubuntu-latest default toolchains on PATH and more versions in /opt/hostedtoolcache). A
job that declares either installs and runs inside that Nix closure. That decision
comes entirely from the job’s own keys, never from its runs-on label.
Self-hosted runners
Section titled “Self-hosted runners”You can attach your own hardware to the pool. Registration, job claim by
long-poll, lease heartbeat and completion all work, driven by an agent that is
embedded in the neo CLI — there is no second binary to install:
neo runner startZero-arg inside a pushed checkout with a stored login. start registers first if
this machine has no persisted credentials yet, so it is the only command most
people need; neo runner register exists for scripts that want registration as
its own step. Credentials persist to ~/.config/neosource/runner.json. Get the
CLI from Install the CLI.
Two flags decide what the machine will accept:
--labels(defaultself-hosted,linux,x64) is what the runner advertises forruns-onmatching.--serve-default-labelsalso advertises the hosted labels (ubuntu-latest,ubuntu-24.04,ubuntu-22.04) so unmodified workflows can land here. Labels match case-insensitively.- Registration is actor-bound by default: it claims only jobs from runs you
triggered — your pushes, your PRs, your dispatches.
--workspace-wideclaims any job in the workspace and requires workspace admin, which the server enforces.
Two operational rules worth knowing:
- A runner registration is swept after 24 hours without claiming a job, so a
machine left off for a day comes back to a dead credential.
neo runner starthandles this for you, whether it was running the whole time or has just been restarted: when the scheduler stops recognising the credential it drops the claim loop, registers again, and resumes. You do not have to re-runneo runner registerby hand. The one limit is a bounded retry budget — five revocations in quick succession and the agent gives up and exits non-zero rather than churning against a scheduler that keeps rejecting it. - The hosted pool stays the fallback. A self-hosted machine that dies mid-run does not strand the job — the lease expires and the job is requeued once; a second infrastructure failure of the same job fails it.
Self-hosted VM runners
Section titled “Self-hosted VM runners”The neo CLI’s agent runs a job’s steps directly on the machine and does not
start services:. A Linux machine with KVM can instead run the same
cloud_hypervisor backend the hosted pool uses: the standalone ns-runner
agent built with its direct-vm feature boots one VM per job, with the hosted
pool’s sizes, cache disks, dockerd and services:. It refuses to start on a
host that cannot jail each VM. Put its data directory on a filesystem with
reflink copies (XFS or btrfs): there each job’s disks are forked instantly,
elsewhere every fork is a full copy. It runs only as many VMs at once as its
data filesystem can hold the job disks of beside its disk budgets
(--vm-cache-budget, --vm-packages-budget, --vm-roots-budget), and
refuses to start when that is not even one; lower the budgets on a small
disk.
Seeing your runners
Section titled “Seeing your runners”Two pages in the web UI list self-hosted runners, and so does the CLI:
- Your runners — Settings → Runners (
/settings/runners): the machines you registered to run your own jobs, in any workspace, plus every runner in your personal workspace. Shared runners you registered in an organization with--workspace-wideare not listed here, since they belong to the organization rather than to you: find them on that organization’s Runners page. - An organization’s runners — the organization’s Settings → Runners
(
/<org>/settings/runners), linked from the sidebar. What you see depends on your role:- Admins and owners see every runner attached to the organization: shared
runners (
--workspace-wide) and every member’s own machines. They can deregister any of them. - Members see the shared runners, read-only, and their own machines, which they can deregister. They never see another member’s machines.
- Admins and owners see every runner attached to the organization: shared
runners (
neo runner listprints your runners, the same set as your Runners page;neo runner list --org <slug>prints an organization’s, with the same visibility as its page.--jsonfor scripts.
Each row shows whether the runner is online, busy running a job, or offline (it has not polled recently), whether it is shared or someone’s own, its labels, and what the agent reported about its machine: operating system, architecture, CPU, core count and memory. The agent reports this when it registers and again every time it starts. A runner registered by an older CLI that did not report reads unknown until it is restarted with a current one.
A runner’s name defaults to the machine’s hostname. Pick another with --name
on neo runner start (or register); it is remembered in runner.json until
you pass a different one.
Deregistering a runner revokes its credential immediately. A job it was running goes back in the queue.
Enrolling machines without a login
Section titled “Enrolling machines without a login”For a fleet or a headless box, mint an enrollment token with a credential that
carries workspace:write (set NEOSOURCE_TOKEN to a PAT or service token):
neo runner enroll createneo runner enroll listneo runner enroll revoke <id> [--cascade] [--node <node-id>]The nse_ token is shown once. On the machine, run
neo runner register --enroll <nse_token> (or start --enroll); no login is
needed and the token is never written to disk. Enrollments default to a single
use and one hour, up to 30 days. Re-enrolling the same machine replaces its old
runner, and that runner’s job goes back in the queue. Each redemption re-checks
that the creator may still register runners: a demoted creator or a revoked
creating token gets a 403. Workspace-wide machines enrolled this way are not
swept as stale. If an enrolled runner’s credential is later rejected, it exits
and asks you to enroll again.
The run page says where each job ran. A job on a shared runner names that runner, so choose shared runner names you are happy for anyone who can see the run to read. A job on someone’s own machine reads “ran on @handle’s machine”: that runner’s name is never shown on a run page, since it is usually a hostname and runs can be public. Hosted jobs read “ran on hosted”.
Local-first CI
Section titled “Local-first CI”Running your own push’s CI on your own machine while the server still records the run — real run records, real logs, visible in the UI, just executed locally:
neo push --runnerAn ephemeral actor-bound session runner registers before the push, claims that push’s jobs, streams their logs into your terminal, and exits when the runs finish. The hosted pool is the fallback if this machine dies mid-run.
Seeing what ran
Section titled “Seeing what ran”Runs, jobs, and logs are all readable through the API and the Actions view in the web UI. The endpoint list is in How CI works here.