Set up a self-hosted runner¶
A runner is a declaration, not a procedure. One row in inventory/runners.yaml says what GitLab is
told and how the supervisor on the host is configured; the ci-runner cookbook renders the rest and
converges it. There is no script to run on the box and no file to edit there.
Why the estate runs its own at all: Nerthus is on GitLab Free with 400 CI minutes a month, and a self-hosted runner's minutes do not count against them (Check the quota).
Declare it¶
# Nerthus.Infra/inventory/runners.yaml
gitlab_runner:
nerthus-host-2:runner-1:
host: nerthus-host-2
description: nerthus-host-2:runner-1
tags: [arm64-emulation, nerthus-amd64]
run_untagged: true
concurrent: 3
memory: 4096m
cgroup_parent: ci.slice
slice_memory_max: 14G
slice_memory_high: 13G
slice_cpu_quota: 1000%
apparmor_profile: nerthus-ci
stack_reserve_mb: 0
token_credential: RUNNER_AUTH_TOKEN_HOST2
The id is <host>:<name> and the description is the id. That is what makes a runner adoptable by
name: GitLab identifies a runner by its description alone, so one described anything else can be
read but never matched to a declaration.
Every attribute is in the resource model, which nerthus-config schema export --what model prints.
The ones GitLab answers for — description, tags, run_untagged, locked, paused — are compared
on every plan; the rest are local, because no API can tell you how much memory a job container may
have.
Tags are written sorted, and a capability produces one. GitLab holds them as a set and answers
them sorted, so an unsorted declaration plans an update about nothing else. A tag exists so that a
job that needs something this host has — buildah, arm64 emulation — can ask for it by name rather
than landing somewhere by chance.
Converge it¶
nerthus-config cookbook plan ci-runner --host nerthus-host-2
nerthus-config cookbook apply ci-runner --host nerthus-host-2 --no-dry-run
In steady state neither is run by hand: the agent on the host converges its own roles from
Nerthus.Infra's main.
The cookbook, in order: GitLab's signing key against the digest the pins name, the repository, the
pinned gitlab-runner, the ci.slice unit, the AppArmor profile, the emulation handler where the
host declares one, the configuration, the token, the supervisor.
The two numbers and the slice that bounds them¶
memory caps one job container. concurrent multiplies it. There is no per-job override
anywhere in GitLab — a pipeline cannot ask for more than its runner's cap — so the cap is sized to
the heaviest job the queue carries, and ci.slice is what bounds their sum.
slice_memory_max therefore has to hold concurrent × memory, and validate refuses a row where it
does not. It also has to leave room for everything else the machine runs: stack_reserve_mb is what
the site stacks are already promised, and the cookbook's own fact reads the host's real memory and
refuses a slice that does not fit beside it. A host that declared more than it has gets the kernel
choosing a victim by resident size across the whole machine — which is how a site's daemon dies
because a CI job was greedy.
The failure mode a slice buys is the one to want: under pressure the kernel kills inside it, so a job dies and is retried.
The token¶
It is minted once, at registration, and GitLab shows it once. It reaches the host as a file under
/run/nerthus/, on tmpfs, decrypted from vault/hosts/<host>/ by the agent; the cookbook
substitutes it into /etc/nerthus/runner/config.toml, mode 0600, which is where ruling 53 lets a
secret rest.
Never re-provision from the token in config.toml
The supervisor rotates its own token and rewrites that file. Feeding the rotated value back is
how both of this estate's runners once answered 403 to everything. Rotation is
reset_authentication_token at GitLab, and the new value arrives as a new vault file.
The distributed cache works the same way and is all five values or none: bucket, endpoint, location and the two credential names. A half-configured S3 cache logs one line per operation and lets the job go green, which reads as a working cache until somebody times it.
The security posture¶
The supervisor mounts the Docker socket so it can start each job as a sibling container. That is effectively root on the machine, and it is what "run this only on a host you trust with the group's CI" means.
A job gets privileged = false, one cache volume, no host path, no daemon socket — and
seccomp=unconfined, which is real and is argued where it is rendered: buildah re-execs into a user
namespace and the default profile blocks the syscalls that needs.
cap_add is not an attribute of this kind, so a declaration asking for one is refused rather than
rendered. SYS_ADMIN is the broadest capability the kernel has and the key is runner-wide.
AppArmor is per host. nerthus-ci is docker-default plus the four rules a rootless image build
needs. unconfined is permitted — some kernels mediate the mount in a way no profile text satisfies
— but validate refuses it unless that runner carries a tag and sets run_untagged: false, so the
reduction is spent on the jobs that need it and not on every job in the group. AlmaLinux mediates
with SELinux and renders no profile at all.
Families¶
Debian, Ubuntu and AlmaLinux. Alpine has no arm: gitlab-runner is packaged for no Alpine
repository, and validate refuses the role on an Alpine host rather than converging a runner that
cannot exist. AlmaLinux installs no emulation handler either — qemu-user-static is EPEL's on that
family, measured absent from both BaseOS and AppStream — so a host declaring arm64_emulation there
is refused by the cookbook's own fact.
Try it where it can do no harm¶
A fresh instance on the lab host, converged twice: the second run must carry out nothing.
The two runners that exist today¶
nerthus-host-1 and nerthus-host-2 were provisioned before this cookbook and are described by
their bare hostnames, which no declaration can name. They keep answering the group's jobs, and each
is re-described and converged when its host is adopted. Until then they read as residue in a live
plan, and no apply is narrowed to gitlab_runner: it would delete both.
See also¶
- Move a runner — changing which machine carries one
- Set up pipelines — the jobs a runner executes
nerthus-config schema export --what model— every attribute of a runner row, from the table the validator itself reads