Skip to content

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 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.

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.

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 way

packages: 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.

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:

Terminal window
neo runner start

Zero-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 (default self-hosted,linux,x64) is what the runner advertises for runs-on matching. --serve-default-labels also 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-wide claims 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 start handles 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-run neo runner register by 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.

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.

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-wide are 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.
  • neo runner list prints 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. --json for 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.

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):

Terminal window
neo runner enroll create
neo runner enroll list
neo 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”.

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:

Terminal window
neo push --runner

An 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.

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.