Dieser Blog macht nichts Besonderes — und genau das ist der Punkt. Es gibt keinen Server, auf dem ich mich einlogge, kein rsync, kein FTP. Ich schreibe eine Markdown-Datei, mache git push, und ein paar Minuten später steht der neue Beitrag online. Dazwischen läuft eine Kette aus lauter kleinen, für sich genommen langweiligen Bauteilen — und die schaue ich mir hier einmal Stück für Stück an.

Die Kette im Überblick

     lokal
     ─────
     vim content/posts/*.md
          │  git commit
          │  git push origin main
 ══ CI ══ Codeberg · Forgejo Actions ═══════════════════════════
     hugo --minify  →  docker build  →  push image  :latest :<sha>
          ▼  neues OCI-Image in der Registry
 ══ CD ══ fxb-k8s-cluster · Argo CD ════════════════════════════
     Image Updater  →  Digest nach Git  →  sync  →  Deployment
 Besucher ──TLS──▶ Charon ──HTTP──▶ Cluster-Traefik ──▶ nginx :8080

Kein Schritt davon ist neu erfunden — die Cluster-Website nebenan läuft schon genauso. Der Blog klinkt sich einfach in dieselbe Infrastruktur ein. Jetzt aber der Reihe nach.

1. vim — eine Datei, mehr nicht

Ein Beitrag ist eine einzelne Markdown-Datei unter content/posts/. Oben ein bisschen Front-Matter, darunter Text:

---
title: "Mein Beitrag"
date: 2026-07-05
tags: ["kubernetes"]
summary: "Kurzbeschreibung für Liste und RSS."
draft: true   # entfernen zum Veröffentlichen
---

Der eigentliche Text …

Solange draft: true gesetzt ist, taucht der Beitrag nur lokal auf (hugo server -D → Live-Vorschau auf localhost:1313), aber nie im CI-Build. Kein Editor-Plugin, kein CMS, keine Datenbank — nur eine Textdatei, die sich versionieren lässt.

2. git commit — die Absicht festhalten

Der Commit ist ein Schnappschuss samt Begründung. Er passiert lokal und tut noch gar nichts nach außen — nichts ist deployt, nichts ist öffentlich. Er hält nur fest: das ist der Stand, den ich für gut befinde. Genau das macht später den Rollback so billig — ein früherer Commit ist eine frühere Version des Blogs.

3. git push — der einzige Auslöser

git push origin main ist der einzige Knopf im ganzen Ablauf. Kein SSH auf irgendeinen Server, kein manuelles Kopieren. Der Push landet auf Codeberg und stößt dort — sofern sich relevante Dateien geändert haben (Inhalt, Layout, Config; nicht z. B. die README) — die CI-Pipeline an. Ab hier fasse ich nichts mehr an.

4. Forgejo Actions — der CI-Teil

Codeberg läuft auf Forgejo, und dessen Actions sind die CI. Der Job selbst wird von einem Runner ausgeführt, der im Cluster sitzt (Docker-in-Docker) — der Build passiert also auf meiner eigenen Hardware, nicht auf fremden Minutenkontingenten.

Zwei Dinge passieren dort: Hugo baut die statische Seite, und das Ergebnis wird in ein Container-Image gepackt. Beides steckt in einem Multi-Stage-Dockerfile:

FROM hugomods/hugo:exts AS build
WORKDIR /src
COPY . .
RUN hugo --minify

FROM cgr.dev/chainguard/nginx:latest
COPY --from=build /src/public /usr/share/nginx/html

Die erste Stage rendert content/ zu fertigem HTML nach public/. Die zweite kopiert dieses public/ in ein schlankes, non-root Chainguard-nginx (hört auf Port 8080) — ohne Shell, ohne Paketmanager, nur Webserver und statische Dateien. Das fertige Image wird mit zwei Tags in die Codeberg-Registry gepusht: :latest und :<commit-sha>. Damit gilt: eine Version des Blogs = ein Image, unveränderlich und eindeutig benannt.

Wichtig ist, was die CI nicht tut: Sie hat keinerlei Zugriff auf den Cluster. Sie legt nur ein Image in einer Registry ab. Mehr Rechte braucht sie nicht.

5. Argo CD — der CD-Teil (Pull statt Push)

Hier kommt der Trick von GitOps: Der Cluster wird nicht bespielt, er holt sich seinen Sollzustand selbst. Zwei Komponenten arbeiten zusammen:

  • Der Argo CD Image Updater beobachtet die Registry. Taucht ein neues Image auf, schreibt er dessen Digest zurück ins GitOps-Repo — als ganz normalen Git-Commit. Der gewünschte Zustand des Clusters lebt also in Git.
  • Argo CD selbst vergleicht permanent Git (soll) mit Cluster (ist). Sieht es einen Unterschied — hier: den neuen Image-Digest — synchronisiert es ihn in den Cluster.

Der Cluster zieht sich seine Updates also selbst aus Git, statt dass eine Pipeline von außen hineindrückt. Das ist nicht nur sauber nachvollziehbar (jede Änderung ist ein Commit), sondern auch sicherer: Von außen führt kein Weg mit Cluster-Rechten hinein.

6. Deployment & Auslieferung

Der Sync ändert am Ende ein Feld im blog-Deployment: den Image-Digest. Kubernetes macht daraus ein Rolling Update — ein neuer Pod mit dem neuen Image startet, der alte wird erst dann abgeräumt, wenn der neue läuft. Kein Ausfall, kein Wartungsfenster.

Der Weg eines Requests bis zum fertigen Byte:

Besucher
  │  HTTPS
Charon (Traefik, extern)      TLS-Terminierung, Let's Encrypt
  │  HTTP  (nur noch im LAN)
Cluster-Traefik (web / :80)   Routing anhand des Hostnamens
nginx-Pod (:8080)             liefert das vorgebaute HTML aus

TLS endet außen am Reverse Proxy; dahinter spricht alles nur noch schlichtes HTTP im LAN. nginx serviert reine, im Voraus gebaute Dateien — mit gzip, Security-Headern und einer strengen Content-Security-Policy. Kein Tracker, kein externes JavaScript, kein CDN — alles self.

Warum Hugo

Ein einzelnes Go-Binary, kein node_modules, Builds in Sekunden. Für einen Textblog gibt es wenig Gründe, komplizierter zu werden — und weil das Ergebnis statisches HTML ist, gibt es zur Laufzeit nichts, was kaputtgehen oder angegriffen werden könnte.

Warum im eigenen Cluster

Weil es geht — und weil jeder Dienst, der hier läuft, ein Stück weniger Abhängigkeit von fremder Infrastruktur bedeutet. Der Runner, die Registry, das Deployment, die Auslieferung: alles auf eigener Hardware, alles nachvollziehbar in Git.

Mehr dazu in den kommenden Beiträgen. Bis dahin: >_