forgejo-runner
CI runner daemon for Forgejo / Gitea Actions.
The service runs in a NixOS container. It connects to a Forgejo or Gitea instance, polls for jobs,
and executes them using Docker-in-Docker. idleTimeout is null — the runner must stay connected
to the job queue continuously. No ports are exposed; the runner communicates outbound only.
| Attribute | Value |
|---|---|
| Runtime | Native (NixOS container) |
| Ports | — (outbound only) |
| Idle timeout | None (always-on) |
| OCI image | — |
Call shape
This blueprint is parameterized: it is a function of a runners attrset rather than a plain
service definition, so it is not reachable through config.nixda.blueprints. Import the file and
call it. Each key names one runner; each value carries that runner’s overrides.
let
forgejoRunner = import "${inputs.nixda-stack}/modules/catalog/blueprints/forgejo-runner.nix";
in
{
nixda.zones.management.services.forgejo-runner = lib.recursiveUpdate
(forgejoRunner {
nixda = { };
nixos = {
uuid = "f543b661-cb02-4ba2-9820-108df62808b5";
token = "/run/secrets/forgejo_conn_nixos";
labels = [ "nixos:host" ];
};
})
{ id = 50; };
}
Per-runner overrides: url (default "https://codeberg.org"), name (default: the attrset
key), labels (default: docker:docker://node:22-bookworm,
ubuntu-latest:docker://catthehacker/ubuntu:act-latest).
Cache ports are indexed once across all runners, from base 8088, so no two runners collide — but
the blueprint only renders a cache block for legacy and declarative runners. An external runner’s
cache configuration is whatever its own config.yaml specifies.
Credential forms
A runner uses exactly one of three forms: legacy, declarative, or external. The following are
evaluation errors, each naming the offending runner:
tokenFilecombined withuuid,token, orconfigFileconfigFilecombined withuuid- half of the declarative pair (
uuidwithouttoken, ortokenwithoutuuid, whenconfigFileis absent) configFilewithouttoken
The check runs over every runner, so a well-formed runner never masks a malformed sibling.
Legacy — one-shot registration token
Selected by declaring none of uuid, token, or configFile. The runner registers itself against
the forge on first start and persists a .runner file.
| Key | Meaning |
|---|---|
tokenFile | Host path of the registration-token secret. Default: /run/secrets/runner_token_<name> |
forgejoRunner {
nixda = { };
bergwerk = { };
}
Declarative — connection triple
Selected by declaring both uuid and token. The runner is registered against the forge out of
band; forgejo-runner v12 then reads the connection from server.connections in config.yaml.
There is no registration step and no .runner file. The call-shape example above declares a
runner in this form.
| Key | Meaning |
|---|---|
url | Forge base URL |
uuid | UUID of the already-registered runner |
token | Host path of that runner’s connection-token secret |
labels | Labels for this connection, replacing the top-level list |
The rendered config.yaml — including the plaintext uuid — is written to the Nix store and to
/etc/forgejo-runner/<name>.yaml, both world-readable. Use the external form instead when uuid
must not sit in the clear (see below).
The token itself never reaches the Nix store. The host secret is passed into the container as a
systemd credential, republished to the runner unit via LoadCredential=, and referenced from the
rendered config as token_url: file:$CREDENTIALS_DIRECTORY/token.
Declarative runners bypass services.gitea-actions-runner: that module exposes no uuid, and its
registration ExecStartPre cannot be switched off through its option surface. They get their own
forgejo-runner-<name>.service instead. Legacy runners keep going through the upstream module
unchanged, so all forms coexist in one runner set.
External — consumer-rendered config
Selected by declaring both configFile and token. The blueprint renders nothing for this
runner: no store artefact, no /etc/forgejo-runner/<name>.yaml. The consumer renders the whole
config.yaml themselves — typically via a sops-nix template — and points configFile at the
result.
| Key | Meaning |
|---|---|
configFile | Host path of a config.yaml the consumer renders |
token | Host path of that runner’s connection-token secret |
forgejoRunner {
extern = {
configFile = "/run/secrets/rendered/forgejo-runner-extern.yaml";
token = "/run/secrets/forgejo_conn_extern";
};
}
The rendered file is passed in as the runner_config_<name> credential. The unit’s
LoadCredential= loads both token:... and config:...; ExecStart reads the config as
%d/config — systemd’s credentials-directory specifier, used because ExecStart is not
shell-expanded. Contrast the declarative form’s token_url: file:$CREDENTIALS_DIRECTORY/token,
which forgejo-runner itself substitutes at runtime: that substitution is why token_url can use
the environment variable and --config cannot.
Like declarative runners, external runners bypass services.gitea-actions-runner and get their
own forgejo-runner-<name>.service.
Reach for external when a connection field must not sit in the Nix store. uuid has no
file-or-env indirection in forgejo-runner — only token_url does — so an encrypted-at-rest uuid
is reachable only by rendering the whole file at activation.