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.

AttributeValue
RuntimeNative (NixOS container)
Ports— (outbound only)
Idle timeoutNone (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:

  • tokenFile combined with uuid, token, or configFile
  • configFile combined with uuid
  • half of the declarative pair (uuid without token, or token without uuid, when configFile is absent)
  • configFile without token

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.

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

KeyMeaning
urlForge base URL
uuidUUID of the already-registered runner
tokenHost path of that runner’s connection-token secret
labelsLabels 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.

KeyMeaning
configFileHost path of a config.yaml the consumer renders
tokenHost 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.


Back to Blueprint Catalog