Ein Blog, der sich jetzt selbst baut
Vor ein paar Monaten habe ich hier beschrieben,
wie dieser Blog von git push bis “live im Cluster” durchläuft: Hugo baut,
Docker packt, die Registry lagert, Argo CD holt sich das Ergebnis. Eine schöne,
saubere GitOps-Kette — nur eben ziemlich viele Bauteile für am Ende zwei
statische HTML-Verzeichnisse.
Diese Woche ist die Kette kürzer geworden. Blog und Website laufen nicht mehr
im fxb-k8s-Cluster, sondern auf einem einzelnen, frisch aufgesetzten
NixOS-Server (fxb-srv-n1x-01). Kein Docker mehr, keine Container-Registry,
kein Argo CD — und trotzdem: git push, ein paar Sekunden warten, fertig.
Wie das geht, warum ich das gemacht habe und wo ich mir dabei mehrfach selbst
im Weg stand, kommt jetzt.
Die alte Kette zum Vergleich
lokal
─────
git push origin main
▼
══ CI ══ Codeberg · Forgejo Actions (Runner: DinD im Cluster) ═══
hugo/astro build → docker build → push image :latest :<sha>
▼
══ CD ══ fxb-k8s-cluster · Argo CD ═══════════════════════════════
Image Updater → Digest nach Git → sync → Rolling Update
▼
Besucher ──TLS──▶ Charon ──HTTP──▶ Cluster-Traefik ──▶ nginx-Pod :8080
Fünf Systeme mussten mitspielen, damit ein Tippfehler-Fix online geht: Runner, Registry, Image Updater, Argo CD, Kubernetes selbst. Für zwei zustandslose, rein statische Sites ist das ehrlich gesagt eine Menge Maschinerie.
Die neue Kette
lokal
─────
git push origin main
▼
══ CI+CD in einem ══ Codeberg · Forgejo Actions ═══════════════════
Runner läuft DIREKT auf fxb-srv-n1x-01 (Label "native", Backend "host")
hugo --minify / npm run build
▼
rsync public|dist/ → /srv/www/<site>/releases/<sha>/
ln -sfn releases/<sha> /srv/www/<site>/current
▼
Besucher ──TLS (Traefik, Let's Encrypt HTTP-01)──▶ nginx (127.0.0.1) ──▶ Datei
Kein Image, keine Registry, kein Digest, kein zweites System, das sich erst
noch synchronisieren muss. Der Runner ist der Zielserver. Build und
Auslieferung passieren auf derselben Maschine — der letzte Schritt ist im
Kern ein cp mit Kür.
Warum der Wechsel
Drei Gründe, absteigend nach Ehrlichkeit sortiert:
- Weniger Umwege für rein statische Inhalte. Ein Kubernetes-Deployment, ein Image, ein Digest-Update-Commit, ein Sync — für zwei Verzeichnisse mit HTML/CSS/JS ist das viel Zeremonie. Ein NixOS-Host mit Traefik + nginx kann dasselbe mit deutlich weniger beweglichen Teilen.
- Ich wollte den neuen
forgejo-runner-Baustein (UUID+Token-Registrierung statt des klassischen Registrierungstokens) ausprobieren — dazu gleich mehr, das war tatsächlich der Auslöser für eine kleine Kaskade an Entscheidungen. - Und ja: neuere Software auf dem neuen Server ist einfach angenehmer als das
konservative
stable-Gleis, das die anderen Server hier fahren.
Der fxb-k8s-Cluster bleibt davon unberührt bestehen — Vaultwarden, Immich,
Paperless-ngx, Jellyfin und der Rest laufen weiter dort. Nur die beiden
zustandslosen Static-Sites sind ausgezogen.
Schritt für Schritt
1. Der Runner läuft jetzt auf dem Zielserver
Im alten Setup saß der Forgejo-Runner im Cluster und sprach per
Docker-in-Docker mit einem Daemon, nur um am Ende ein Image zu bauen, das
irgendwo anders wieder ausgepackt wird. Jetzt registriert sich eine
forgejo-runner-Instanz direkt auf fxb-srv-n1x-01, mit einem einzigen Label:
services.forgejo-runner.instances.default = {
enable = true;
settings.runner.labels = [ "native:host" ];
settings.server.connections.default = {
url = "https://codeberg.org/";
uuid = "…";
};
secrets.server.connections.default.token_url = config.sops.secrets.forgejo_runner_token.path;
hostPackages = with pkgs; [ bash coreutils curl gawk gnused nodejs wget rsync hugo ];
};
native:host heißt: keine Container-Isolation zwischen Job und Maschine,
der Workflow läuft mit genau den Programmen, die in hostPackages stehen.
Für Astro reicht nodejs, für Hugo eben hugo — beide Sites teilen sich
dieselbe Runner-Instanz.
2. Der Build wird stinklangweilig
# fxb-k8s-blog/.forgejo/workflows/build.yml
- name: Build
run: hugo --minify --gc --baseURL https://blog.fuxx.space/
- name: Deploy
run: |
set -euo pipefail
RELEASE="/srv/www/blog/releases/${{ github.sha }}"
rsync -a --delete public/ "$RELEASE/"
ln -sfn "$RELEASE" /srv/www/blog/current
Kein Dockerfile, keine zweite Build-Stage, kein docker login. Jede
github.sha-Version landet in einem eigenen Verzeichnis unter releases/,
und ein einziger Symlink-Swap entscheidet, welche Version gerade “live” ist —
alte Releases bleiben liegen (räume ich irgendwann mal auf), aber ein
halbfertiger Build ist nie live sichtbar, weil current erst nach
erfolgreichem rsync umgehängt wird.
3. Traefik übernimmt, was vorher Cluster-Traefik + Charon gemacht haben
myModules.traefik.routers.blog.rule = "Host(`blog.fuxx.space`)";
myModules.traefik.routers.blog.port = 8082;
myModules.traefik.redirectRouters.blog = {
rule = "Host(`www.blog.fuxx.space`) || Host(`blog.fuxxbau.net`) || Host(`www.blog.fuxxbau.net`)";
target = "https://blog.fuxx.space";
};
Kanonisch ist blog.fuxx.space; die drei anderen Domains, unter denen der
Blog ebenfalls erreichbar sein soll, leiten per 301 dorthin um, statt
denselben Inhalt viermal auszuliefern. Zertifikate holt sich Traefik selbst
per Let’s-Encrypt-HTTP-01 — kein cert-manager, kein ClusterIssuer,
keine zweite Instanz für IPv6 wie im Cluster. Ein Prozess, ein Zertifikat pro
Domain-Satz.
Stolpersteine
Und jetzt der Teil, für den sich dieser Beitrag eigentlich lohnt — nichts davon lief beim ersten Versuch durch.
forgejo-runner vs. gitea-actions-runner: zwei Module, zwei Welten
Der neue services.forgejo-runner (UUID+Token) existiert nur in
nixpkgs-unstable — auf dem stabilen Kanal, auf dem dieser Server ursprünglich
laufen sollte, gibt es nur das ältere services.gitea-actions-runner mit dem
klassischen Registrierungstoken-Flow. Ich habe zunächst mit dem alten Modul
angefangen (funktioniert, registriert sich bei jedem Start selbst neu), dann
aber umentschieden und den ganzen Host auf nixpkgs-unstable umgehängt, nur
um das neue Modul nutzen zu können. Das ist ein Kompromiss: der Server
verliert damit die “läuft unbeaufsichtigt auf stable”-Garantie, die die
anderen Server hier haben.
Fallstrick dabei: Die beiden Module sind nicht kompatibel ineinander
migrierbar — unterschiedliche Optionsnamen, unterschiedliche systemd-Unit-Namen
(gitea-runner-default vs. forgejo-runner-default), unterschiedliches
Registrierungsmodell. Ein Umstieg mittendrin bedeutet: altes Modul komplett
raus, neues komplett rein, keine Zwischenstufe.
ACME bündelt alle Domains — eine kaputte reißt alle mit
Traefik fasst bei einem Router mit mehreren Host()-Regeln alle Domains zu
einem Zertifikatsantrag zusammen. Als ich die Website auf den neuen Server
umgezogen habe, zeigte fuxx.space und fuxxbau.net schon auf die neue IP,
www.fuxxbau.net aber noch auf die alte (Hetzner-DNS, längere TTL) — und
Let’s Encrypt hat das ganze Zertifikat verweigert, nicht nur den einen
Hostnamen:
ERR Unable to obtain ACME certificate for domains
error="… www.fuxxbau.net: invalid authorization … 404"
domains=["fuxx.space","fuxxbau.net","www.fuxxbau.net"]
Merke: Bei mehreren Domains in einer Rule zieht der langsamste DNS-Eintrag alle anderen mit runter. Erst wenn wirklich alle propagiert sind, klappt irgendeine.
nginx: add_header in einem Location-Block verwirft die geerbten
Eine nginx-Eigenheit, die mich schon einmal woanders gebissen hat und jetzt
wieder zuschlug: Setzt ein location-Block selbst add_header, werden
sämtliche add_header der übergeordneten Ebene verworfen, nicht ergänzt.
NixOS bringt für services.nginx einen eingebauten Gixy-Lint
mit, der genau das beim Bauen abfängt:
Problem: [add_header_redefinition] Nested "add_header" drops parent headers.
Reason: Parent headers "x-frame-options", "content-security-policy", … dropped
Betroffen waren die Cache-Location (/_assets/, gefingerprintete Dateien) und
die WKD-Location (.well-known/openpgpkey) — beide setzen eigene Header und
haben damit unbemerkt die CSP/Security-Header der übrigen Seite gekippt.
Lösung: die Security-Header in jedem Location-Block wiederholen, der selbst
welche setzt. Lästig, aber wenigstens bricht der Build und nicht erst der
Pentest.
DynamicUser und ein Zielverzeichnis, das schon existieren muss
services.forgejo-runner läuft standardmäßig unter einem DynamicUser —
eine bei jedem Start neu vergebene UID. Praktisch für Isolation, unpraktisch
für mein rsync-Ziel: systemd.tmpfiles.rules legt /srv/www/blog beim
Boot an, bevor der Runner je gelaufen ist — zu diesem Zeitpunkt existiert
die dynamische UID aber noch gar nicht, chown auf einen unbekannten Nutzer
schlägt fehl. Ergebnis: ein Henne-Ei-Problem.
systemd.services."forgejo-runner-default".serviceConfig = {
DynamicUser = lib.mkForce false;
User = lib.mkForce "wwwdeploy";
Group = lib.mkForce "wwwdeploy";
};
Fix: den Modul-Default per lib.mkForce auf einen festen, selbst angelegten
Nutzer umbiegen. Weniger elegant als ein waschechter DynamicUser, aber
vorhersagbar — und Vorhersagbarkeit gewinnt, wenn tmpfiles und Runner in der
richtigen Reihenfolge zusammenspielen müssen.
Die WKD-Subdomains hätte ich fast vergessen
Die Website bedient nicht nur fuxx.space/fuxxbau.net, sondern auch
openpgpkey.fuxx.space/openpgpkey.fuxxbau.net — das Web Key Directory in
der “advanced method”. Das stand aber nirgends im Website-Repo selbst, sondern
nur in der GitOps-IngressRoute des alten Clusters. Hätte ich nicht noch einmal
gezielt nachgeschaut, liefe die OpenPGP-Schlüsselsuche nach dem Umzug still
und leise ins Leere — kein Fehler, kein Alarm, einfach ein 404, den niemand
bemerkt, bis eine verschlüsselte Mail nicht ankommt.
Lehre: Bei einem Infrastruktur-Umzug zählt nicht nur, was im Anwendungs- Repo steht, sondern auch alles, was drumherum in GitOps-Manifesten, Ingress-Regeln oder DNS-Einträgen an dasselbe Deployment gebunden ist.
Warum diesmal nicht wieder Docker
Für Immich, Vaultwarden oder Paperless-ngx bleibt Docker/OCI weiterhin die richtige Wahl — echte Dienste mit eigenem Prozess, eigenen Abhängigkeiten, eigenem Lebenszyklus. Für zwei statische Verzeichnisse aus HTML/CSS/JS ist ein Container-Image dagegen vor allem eins: eine Kopieraktion mit Registry-Umweg. Der Inhalt braucht keine Prozessisolation, weil zur Laufzeit gar kein Prozess mehr läuft — nur nginx, das Dateien ausliefert, die schon vorher fertig waren.
Bleibt gleich
Trotz allem Umbau: draft: true versteckt Beiträge weiterhin vor dem CI-Build,
die Security-Header/CSP sind unverändert streng, kein Tracker, kein externes
JavaScript. Nur der Weg dorthin ist kürzer geworden.
Als Nächstes sind Vaultwarden, Paperless-ngx und Immich dran — die drei sind
allerdings echte, zustandsbehaftete Dienste mit echten Daten, kein
copy-paste dieses Umzugs. Dazu mehr, sobald es so weit ist. Bis dahin: >_