Zum Inhalt

CI: Self-hosted Runner (Hetzner)

Alle GitHub-Actions-Jobs dieses Repos können zentral auf selbst gehostete Hetzner-Runner umgeschaltet werden. Ohne Umschaltung laufen sie unverändert auf ubuntu-latest.

Entscheidung (2026-08)

Die CI läuft auf einem privaten Repo, GitHub-gehostete Minuten kosten dort laufend Geld. Selbst gehostete Runner auf Hetzner sind deutlich günstiger, unter eigener Kontrolle und passen zur Self-Hosting-Linie des Projekts.

Bewertete Alternativen:

  1. Alles hart auf runs-on: [self-hosted, hetzner] — einfachste Änderung, aber ohne Fallback: sind die Runner offline oder noch nicht provisioniert, hängt jede CI in der Warteschlange; Rollback braucht einen Commit.
  2. Nur die teuren Gates umstellen, Rest auf GitHub lassen — spart nur einen Teil und hinterlässt zwei Betriebsmodelle in einem Repo.
  3. Zentraler Schalter über eine Repository-Variable mit Fallback (gewählt): eine Variable steuert alle Jobs, Umschalten und Rollback ohne Commit.

Funktionsweise

Jeder Job verwendet dieselbe Zeile:

runs-on: ${{ vars.CI_RUNS_ON && fromJSON(vars.CI_RUNS_ON) || 'ubuntu-latest' }}

Gesteuert wird das über die Repository-Variable CI_RUNS_ON (Settings → Secrets and variables → Actions → Variables):

Zustand Effekt
nicht gesetzt / leer alle Jobs auf ubuntu-latest (GitHub-gehostet)
["self-hosted","hetzner"] alle Jobs auf den Hetzner-Runnern

Der Wert muss ein JSON-Array von Runner-Labels sein. Ein bloßer String (z. B. self-hosted) lässt fromJSON und damit jeden Workflow-Start scheitern.

Die Variable löschen ist der Kill-Switch: Die CI läuft sofort und ohne Commit wieder auf GitHub-gehosteten Runnern, etwa wenn die Hetzner-Runner ausfallen.

Provisionierung

Die Runner selbst kommen aus dem Repo calhelp/github-runner: Terraform provisioniert Hetzner-Server, cloud-init installiert Docker, git und Build-Basis-Pakete, registriert den Runner für calhelp/calserver-yii und startet ihn als systemd-Dienst. Ablauf und Variablen stehen im dortigen README.

Reihenfolge beachten: erst terraform apply, warten bis die Runner unter Settings → Actions → Runners auf „Idle" stehen, dann CI_RUNS_ON setzen.

Mehrere Runner-Instanzen teilen sich eine Maschine

Ein Server trägt mehrere Runner-Instanzen (runners_per_server, bei der Klasse power vier). Jede nimmt einen Job an, alle teilen sich CPU, RAM — und das systemweit installierte Toolchain-PHP.

Das ist die wichtigste Eigenheit dieser Flotte: shivammathur/setup-php installiert und konfiguriert ein PHP pro Maschine. PHP-Version, Extensions und Coverage-Treiber sind damit Maschinenzustand, nicht Job-Zustand. Was ein Job einstellt, erbt der nächste auf derselben Instanz — und weil die Runner nicht ephemeral sind, überlebt das den Workflow-Lauf.

Beobachtet am 2026-08-24 (Lauf 32781588904): Der Job unit-tests setzt coverage: none und schaltet damit den Coverage-Treiber der ganzen Maschine ab. Der später auf derselben Instanz gelandete coverage-Job starb nach 22 Sekunden mit „Code coverage driver not available" — vor dem ersten Test, während derselbe Job 14 Minuten vorher auf einer anderen Instanz grün war. Kein Testbefund, ein Wettlauf um den PHP-Zustand der Maschine.

Konsequenz: die PHP-Jobs des Laravel-Gates laufen im Container. laravel-ci.yml baut dafür ci/Dockerfile.ci-php (Tag aus dem Inhalts-Hash der Datei, Job ci-image) und hängt jeden PHP-Job hinein. Jeder Job bringt sein PHP mit; die Maschine braucht nur noch Docker. Drei Folgen für den Alltag:

  • Extension fehlt? Dann fehlt sie im Image, nicht auf dem Runner — ci/Dockerfile.ci-php ergänzen. Die Abnahme im Dockerfile bricht den Bau ab, wenn eine Extension nicht geladen ist.
  • Dienste werden im Job-Netz angesprochen, also postgres:5432 statt 127.0.0.1:<vergebener Host-Port>. Der Umweg über den Host-Port entfällt und mit ihm die Portkollision zwischen parallelen Jobs.
  • Der Job läuft als root, das Arbeitsverzeichnis gehört dem Runner-Nutzer. Jeder Container-Job schließt deshalb mit .github/actions/container-workspace-handover ab; ohne diesen Schritt bleibt ein root-eigenes vendor/ liegen und der nächste Checkout auf derselben Maschine scheitert.

Workflows außerhalb des Laravel-Gates nutzen weiterhin setup-php bzw. setup-node auf dem Host. Für sie gilt die Warnung oben unverändert: was sie an der Maschine ändern, sehen ihre Nachbarn.

Betrieb

  • Ein Runner arbeitet einen Job gleichzeitig ab. Ein PR startet hier mehrere parallele Gate-Jobs; unter zwei Runnern (runner_count = 2) wird das Feedback spürbar langsamer.
  • Toolchains kommen aus den Setup-Actions. setup-node, setup-php, Playwright --with-deps usw. installieren zur Laufzeit nach; der Runner-Benutzer hat dafür passwortloses sudo. Auf dem Server vorinstalliert sind nur Docker und Basis-Werkzeuge (git, unzip, zip, build-essential). Ausnahme sind die PHP-Jobs des Laravel-Gates: die bringen ihre Toolchain im Container mit (siehe oben).
  • Docker-Images und Caches sammeln sich an, da die Runner nicht ephemeral laufen. Bei knappem Plattenplatz auf den Servern ein periodisches docker system prune -af --volumes (Cron) einrichten oder in github-runner ephemeral = true mit Reprovisionierung nutzen.
  • Sicherheit: Self-hosted Runner nur für private Repos; das PAT liegt nur auf dem Server (root-only) und dient ausschließlich der Registrierung, regelmäßig rotieren. Details im README von calhelp/github-runner.

Jeder Job hat eine Zeitgrenze

Ohne timeout-minutes gilt die GitHub-Vorgabe von sechs Stunden. Auf dieser Flotte ist das teuer: vier Instanzen teilen sich einen Server, ein hängender Job blockiert also ein Viertel der Gesamtkapazität für einen halben Arbeitstag. Alle Jobs in ci.yml, laravel-ci.yml, frontend-v2-ci.yml, mcp-server-ci.yml und license-server-ci.yml tragen deshalb eine Grenze.

Die Werte sind großzügige Vielfache der gemessenen Laufzeit und stehen als Kommentar an der jeweiligen Zeile. Sie sind ein Notaus, kein Budget: ein Job, der seine Grenze reißt, ist kaputt und nicht knapp bemessen. Wer einen Job dauerhaft langsamer macht, hebt die Grenze an, statt sie zu entfernen.

Wenn die Flotte steht

Die Zeitgrenze oben fängt einen Job, der läuft und nicht fertig wird. Sie fängt nicht den Fall, dass der Runner unter dem Job wegbricht: timeout-minutes zählt erst ab Jobstart, ein nie gestarteter Job bleibt queued und läuft in die 24-Stunden-Grenze von GitHub.

Woran man es erkennt

Beobachtet am 2026-08-31, Lauf 33413547325 (CI/CD #2039, develop):

  • Vier Jobs endeten mit failure, ohne dass ihr laufender Schritt endete (Code Style (Pint), npm ci, zweimal Feature Tests stehen bis heute auf in_progress). Ein Job, der an seinem eigenen Code scheitert, meldet einen Fehler im Schritt; hier wurde mitten im Schritt abgeschnitten.
  • Betroffen waren alle vier Instanzen (hcloud-runner-max-2-1 bis -4) innerhalb von dreizehn Minuten (17:44:13 bis 17:56:48 UTC).
  • Acht weitere Jobs desselben Laufs blieben queued und wurden nie angenommen. Repo-weit lief danach kein einziger Job mehr, bei sieben wartenden Läufen.
  • Weil ci.yml mit cancel-in-progress: false arbeitet, hielt der tote Lauf die Concurrency-Gruppe ci-develop besetzt: der Folgelauf #2040 stand auf pending, obwohl niemand mehr rechnete.

Alle vier Instanzen gleichzeitig heißt: die Ursache liegt auf der Maschine, nicht im Diff. Gleichzeitig unterwegs waren zwei feature-tests-Shards (je Job-Container plus je einem Postgres- und einem MySQL-Dienst, dazu php artisan test --parallel), ein Nuxt-Bau und ein PHPStan/Pint-Container. Naheliegend ist erschöpfter Arbeitsspeicher oder volle Platte; letzteres passt zu der oben notierten Eigenheit, dass die Runner nicht ephemeral laufen und Images und Caches sich ansammeln. Der dort empfohlene Prune-Cron ist die Gegenmaßnahme und gehört eingerichtet, wenn er es noch nicht ist.

Was zu tun ist

  1. Kill-Switch ziehen. CI_RUNS_ON löschen (Settings → Secrets and variables → Actions → Variables). Jeder neue Lauf geht sofort und ohne Commit auf ubuntu-latest. Das ist der schnellste Weg zurück zu einer arbeitsfähigen CI.
  2. Den toten Lauf abbrechen. Ein queued gebliebener Lauf räumt sich nicht selbst weg und hält seine Concurrency-Gruppe besetzt, solange er steht. Ohne Abbruch wartet der Folgelauf bis zu 24 Stunden.
  3. Die Maschine ansehen, bevor CI_RUNS_ON zurückgesetzt wird: df -h und free -m, danach docker system prune -af --volumes. Erst wenn die Runner unter Settings → Actions → Runners wieder auf „Idle" stehen, den Schalter zurücksetzen.

Ein abgebrochener develop-Lauf muss nicht nachgeholt werden, solange ein neuerer Lauf denselben Stand enthält: develop baut und deployt den Branch-Kopf, nicht den einzelnen Commit. Bei #2039 war genau das der Fall, e3c68e8c steckt in d03228ce.