Ein Blog, der sich selbst ausliefert
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: >_