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:
- 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. - Nur die teuren Gates umstellen, Rest auf GitHub lassen — spart nur einen Teil und hinterlässt zwei Betriebsmodelle in einem Repo.
- 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:
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-phpergänzen. Die Abnahme im Dockerfile bricht den Bau ab, wenn eine Extension nicht geladen ist. - Dienste werden im Job-Netz angesprochen, also
postgres:5432statt127.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-handoverab; ohne diesen Schritt bleibt ein root-eigenesvendor/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-depsusw. 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 ingithub-runnerephemeral = truemit 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, zweimalFeature Testsstehen bis heute aufin_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-1bis-4) innerhalb von dreizehn Minuten (17:44:13 bis 17:56:48 UTC). - Acht weitere Jobs desselben Laufs blieben
queuedund wurden nie angenommen. Repo-weit lief danach kein einziger Job mehr, bei sieben wartenden Läufen. - Weil
ci.ymlmitcancel-in-progress: falsearbeitet, hielt der tote Lauf die Concurrency-Gruppeci-developbesetzt: der Folgelauf #2040 stand aufpending, 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¶
- Kill-Switch ziehen.
CI_RUNS_ONlöschen (Settings → Secrets and variables → Actions → Variables). Jeder neue Lauf geht sofort und ohne Commit aufubuntu-latest. Das ist der schnellste Weg zurück zu einer arbeitsfähigen CI. - Den toten Lauf abbrechen. Ein
queuedgebliebener 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. - Die Maschine ansehen, bevor
CI_RUNS_ONzurückgesetzt wird:df -hundfree -m, danachdocker 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.