In Teil 1 habe ich den Server aufgesetzt, in Teil 2 liefert er per HTTPS eine Seite aus. Jetzt fehlt noch das Wichtigste für einen Blog: ein bequemer Weg, neue Artikel online zu bringen. Mein Ziel war, dass ich einen Artikel auf dem Laptop schreibe, ihn per git push in meinen eigenen Git-Server schiebe – und der Rest passiert von selbst.
Das klappt inzwischen. Vom Push bis zur fertigen Seite vergehen etwa zehn Sekunden. Wie das aufgebaut ist und warum ich dabei ziemlich paranoid war, zeigt dieser Teil.
Der Plan
Laptop ──git push──▶ Gitea (Homelab) ──▶ Runner (isoliertes LAB-Netz)
│ hugo --minify
▼
rsync über SSH (nur ausgehend)
▼
VPS: Caddy ──▶ https://example.com
Die Bausteine:
- Gitea ist mein selbst gehosteter Git-Server, ein bisschen wie GitHub für zu Hause. Er läuft bei mir schon länger als Docker-Container im Homelab. Gitea bringt mit Gitea Actions eine eigene CI mit (Continuous Integration, also automatische Abläufe bei jedem Push), deren Syntax fast identisch zu GitHub Actions ist.
- Ein Runner führt diese Abläufe aus. Er bekommt bei mir eine eigene kleine VM.
- Hugo baut aus den Markdown-Dateien die fertige Seite.
- rsync über SSH schiebt die Seite zum VPS.
Das Schöne daran: Mein Heimnetz öffnet nach außen gar nichts. Der Runner baut zu Hause und verbindet sich von innen nach außen zum VPS. Es gibt keine Portfreigabe.
Voraussetzungen
| Version / Stand | |
|---|---|
| Gitea | 28.x, als Docker-Container, Actions aktiv |
| Proxmox VE | 9.x (für die Runner-VM) |
| Gitea Runner | Image gitea/runner (der frühere act_runner) |
| Hugo | 0.169.0 extended |
| Theme | PaperMod (als Git-Submodule) |
| VPS | Debian 13 mit Caddy aus Teil 2 |
Beispielwerte in diesem Artikel: git.example.com ist mein Gitea, 10.0.20.10 der Docker-Host, auf dem er läuft, 10.0.99.0/24 das LAB-Netz, 10.0.99.20 der Runner und 203.0.113.10 der VPS.
Schritt 1: Das Hugo-Projekt
Hugo installiere ich lokal direkt aus den offiziellen Releases, weil die Pakete der Distributionen oft hinterherhinken:
mkdir -p ~/.local/bin
URL=$(curl -s https://api.github.com/repos/gohugoio/hugo/releases/latest \
| grep -o 'https://[^"]*hugo_extended_[0-9.]*_linux-amd64\.tar\.gz' | head -1)
curl -L "$URL" | tar -xz -C ~/.local/bin hugo
hugo version
Die Versionsnummer merke ich mir, denn genau diese Version bekommt später auch die Pipeline. Dann sehen die Seite lokal und live garantiert gleich aus.
In Gitea lege ich ein leeres, privates Repository an, klone es und erzeuge darin die Seite:
git clone ssh://git@git.example.com:2222/thomas/kabelbruch.git
cd kabelbruch
hugo new site . --force --format toml
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
cat > .gitignore <<'EOF'
public/
resources/_gen/
.hugo_build.lock
EOF
Als Theme nutze ich PaperMod: schlicht, schnell, mit Hell-Dunkel-Modus, und es braucht weder Node.js noch externe Schriftarten. Mit hugo server -D sehe ich die Seite lokal unter http://localhost:1313 – inklusive Entwürfen.
Schritt 2: Ein Deploy-Schlüssel, der fast nichts darf
Die Pipeline braucht einen SSH-Schlüssel, um Dateien auf den VPS zu schieben. Dieser Schlüssel liegt später als Secret in Gitea und muss ohne Passphrase funktionieren, sonst kann ihn die Pipeline nicht nutzen. Genau deshalb soll er auf dem Server so wenig dürfen wie möglich.
Ein eigener Benutzer auf dem VPS
sudo apt install -y rsync
sudo adduser --system --group --shell /bin/sh --home /home/deploy deploy
sudo chown -R deploy:deploy /var/www/kabelbruch
Der Schlüssel
Auf dem Laptop, ohne Passphrase (-N ""):
ssh-keygen -t ed25519 -f ~/.ssh/kabelbruch_deploy -N "" -C "gitea-deploy-kabelbruch"
cat ~/.ssh/kabelbruch_deploy.pub
Der Trick: rrsync
Auf dem Server trage ich den öffentlichen Schlüssel mit zwei Einschränkungen ein:
sudo mkdir -p /home/deploy/.ssh
echo 'restrict,command="/usr/bin/rrsync /var/www/kabelbruch" ssh-ed25519 AAAA… gitea-deploy-kabelbruch' \
| sudo tee /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
restrictverbietet alles, was SSH sonst noch kann: Terminal, Port-Forwarding, Agent-Forwarding.command="/usr/bin/rrsync /var/www/kabelbruch"sorgt dafür, dass mit diesem Schlüssel ausschließlichrsyncläuft, und zwar nur innerhalb dieses einen Verzeichnisses.rrsync(„restricted rsync“) ist bei Debian imrsync-Paket enthalten. Es ist ein Python-Skript,python3muss also installiert sein.
Selbst wenn jemand den Schlüssel stiehlt, kann er damit nur meine Website überschreiben. Ärgerlich, aber kein Weg auf den Server.
Der Gegentest
$ ssh -i ~/.ssh/kabelbruch_deploy deploy@203.0.113.10
PTY allocation request failed on channel 0
/usr/bin/rrsync error: Not invoked via sshd
Connection to 203.0.113.10 closed.
Genau so soll es aussehen: kein Terminal, keine Shell. Und der echte Upload:
rsync -av --delete -e "ssh -i ~/.ssh/kabelbruch_deploy" /tmp/kb-test/ deploy@203.0.113.10:
Wichtig ist der Doppelpunkt am Ende ohne Pfad. Das Zielverzeichnis legt rrsync auf dem Server fest.
Schritt 3: Der Runner – warum er in ein eigenes Netz kommt
Ein Runner führt Code aus dem Repository aus, und dafür bekommt er in der Regel Zugriff auf das Docker-Socket seines Hosts. Das heißt: Wer den Runner kontrolliert, ist auf dessen Host praktisch root. Mein Docker-Host im Homelab hat Dienste wie meinen Passwortmanager und meine Fotos. Da gehört so etwas nicht hin.
Deshalb bekommt der Runner eine eigene kleine VM in meinem LAB-Netz. Das ist ein VLAN für Testkram, das auf der Firewall vom Rest des Heimnetzes getrennt ist und nur DNS und Internet darf.
Die VM aus dem Cloud-Image
Statt Debian per Hand zu installieren, nehme ich das offizielle Debian-Cloud-Image und lasse cloud-init Benutzer und SSH-Schlüssel beim ersten Start einrichten. Auf dem Proxmox-Host:
mkdir -p /var/lib/vz/import && cd /var/lib/vz/import
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
echo 'ssh-ed25519 AAAA… thomas@laptop' > /root/runner.pub
qm create 108 --name ci-runner --ostype l26 \
--cpu host --cores 2 --memory 4096 \
--scsihw virtio-scsi-single \
--scsi0 ZFS-Pool:0,import-from=/var/lib/vz/import/debian-13-genericcloud-amd64.qcow2,discard=on,iothread=1 \
--ide2 ZFS-Pool:cloudinit \
--boot order=scsi0 \
--net0 virtio,bridge=vmbr0,tag=99,firewall=1 \
--serial0 socket --vga serial0 \
--agent enabled=1 \
--ciuser thomas --sshkeys /root/runner.pub \
--ipconfig0 ip=dhcp --ciupgrade 1 \
--onboot 1
qm disk resize 108 scsi0 32G
qm config 108 | grep net0
ZFS-Pool ist der Name meines Speichers und tag=99 steht hier für die VLAN-ID des LAB-Netzes – beides an die eigene Umgebung anpassen. Aus der letzten Zeile notiere ich mir die MAC-Adresse, denn die VM soll per DHCP-Reservierung immer dieselbe IP bekommen.
Noch nicht starten – erst kommt die Firewall.
Gescheitert, Teil 1: .qcow2.1
wget meldete beim Download: Saving to: 'debian-13-genericcloud-amd64.qcow2.1'. Eine Datei mit demselben Namen lag schon im Verzeichnis, von einem früheren Versuch. Hätte ich das übersehen, hätte qm create das alte Image importiert. Also erst aufräumen:
mv -f debian-13-genericcloud-amd64.qcow2.1 debian-13-genericcloud-amd64.qcow2
Eine einzige Ausnahme in der Firewall
Der Runner muss genau zwei Dinge können: ins Internet (Container-Images, GitHub, SSH zum VPS) und mein Gitea erreichen. Das Internet ist im LAB-Netz ohnehin erlaubt. Für Gitea braucht es eine einzige, möglichst schmale Ausnahme: nur diese eine IP, nur zum Gitea-Host, nur HTTPS.
Auf meiner FortiGate sieht das so aus (Objektnamen an die eigene Konfiguration anpassen):
config firewall address
edit "H_CI_RUNNER"
set subnet 10.0.99.20 255.255.255.255
next
end
config firewall policy
edit 0
set name "RUNNER-to-GITEA"
set srcintf "VL99_LAB"
set dstintf "VL20_SERVER"
set srcaddr "H_CI_RUNNER"
set dstaddr "H_DOCKER_HOST"
set action accept
set schedule "always"
set service "HTTPS"
set utm-status enable
set ips-sensor "default"
set logtraffic all
next
end
Die Warnung „The no-inspection profile doesn’t perform SSL inspection“ beim Speichern ist hier gewollt. Der IPS-Sensor schaut trotzdem mit.
Den Code holt der Runner übrigens per HTTPS mit einem Token, das Gitea für jeden Lauf automatisch erzeugt. Einen SSH-Zugang zu Gitea braucht er nicht.
Eine ehrliche Einschränkung: Gitea läuft bei mir hinter einem Reverse Proxy, der auf derselben IP noch andere Dienste ausliefert. Rein vom Netz her könnte der Runner also auch deren Login-Seiten erreichen – Zugangsdaten hat er natürlich keine. Wer es ganz dicht will, legt im Reverse Proxy zusätzlich eine Zugriffsliste an, die die Runner-IP nur für Gitea zulässt.
VM starten und einrichten
qm start 108
Nach etwa einer Minute ist die VM erreichbar. Vom Laptop aus ein kurzer Test, ob das Netz so funktioniert wie gedacht:
ssh thomas@10.0.99.20
getent hosts git.example.com # DNS
curl -sI https://git.example.com | head -n 1 # Gitea: Firewall-Ausnahme
curl -sI https://github.com | head -n 1 # Internet
Dann Updates, Gast-Agent, automatische Sicherheitsupdates und Docker aus dem offiziellen Repository:
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y qemu-guest-agent unattended-upgrades curl ca-certificates
sudo systemctl enable --now qemu-guest-agent
sudo dpkg-reconfigure -plow unattended-upgrades
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo docker run --rm hello-world
Schritt 4: Den Runner registrieren
Kleine Begriffsverwirrung vorweg: Der Runner hieß früher act_runner und wurde 2026 in Gitea Runner umbenannt. Das alte Docker-Image gitea/act_runner ist als veraltet markiert, aktuell ist gitea/runner.
Den Runner registriere ich nur für dieses eine Repository, nicht für die ganze Gitea-Instanz. Dann führt er auch nur Jobs aus diesem Repository aus. Den Registrierungs-Token gibt es in Gitea unter Repository → Einstellungen → Actions → Runners → „Neuen Runner erstellen“.
sudo mkdir -p /opt/runner && cd /opt/runner
sudo tee compose.yaml > /dev/null <<'EOF'
services:
runner:
image: gitea/runner:latest
container_name: gitea-runner
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=https://git.example.com
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
- GITEA_RUNNER_NAME=ci-runner
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
EOF
echo "RUNNER_TOKEN='<TOKEN_AUS_GITEA>'" | sudo tee .env > /dev/null
sudo chmod 600 .env
sudo docker compose up -d
sudo docker logs -f gitea-runner
Die einfachen Anführungszeichen um den Token in der .env sind Absicht: Sie schützen Sonderzeichen wie $, die Docker Compose sonst als Variable interpretieren würde.
Im Log steht dann zuerst .runner is missing or not a regular file – das ist beim ersten Start normal, die Registrierungsdatei gibt es ja noch nicht – und danach Runner registered successfully.
Den Token wieder loswerden
Der Token wird nur für die Registrierung gebraucht. Danach liegt alles Nötige in data/.runner. Also weg damit:
sudo rm .env
sudo sed -i '/GITEA_RUNNER_REGISTRATION_TOKEN/d' compose.yaml
sudo docker compose up -d
sudo docker logs gitea-runner
Lässt man die Zeile in der compose.yaml stehen, warnt Compose bei jedem Start The "RUNNER_TOKEN" variable is not set. Harmlos, aber unnötig. Wichtig ist, dass beim Neustart keine neue Registrierung im Log auftaucht, sondern nur declare successfully.
In Gitea steht der Runner jetzt mit dem Status Idle unter Einstellungen → Actions → Runners.
Ein kurzer Funktionstest
# .gitea/workflows/runner-test.yml
name: Runner-Test
on: workflow_dispatch
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: head -n 5 hugo.toml
workflow_dispatch heißt: Der Workflow startet nur auf Knopfdruck (Actions → Runner-Test → Workflow ausführen). Beim ersten Lauf lädt der Runner einmalig das Ubuntu-Image, danach war der Test nach 20 Sekunden grün.
Schritt 5: Secrets und der Host-Schlüssel
Die Pipeline braucht zwei Dinge aus Gitea:
1. Den privaten Deploy-Schlüssel als Secret. Einstellungen → Actions → Secrets, Name DEPLOY_KEY, Wert: der komplette Inhalt von ~/.ssh/kabelbruch_deploy inklusive der BEGIN- und END-Zeilen.
2. Den Host-Schlüssel des VPS als Variable. Die Pipeline soll sich nur mit meinem Server verbinden und keinen fremden akzeptieren. Der bequeme Weg wäre StrictHostKeyChecking=no – genau den will ich nicht. Stattdessen hole ich den Host-Schlüssel einmal ab und prüfe ihn gegen:
# auf dem Laptop
ssh-keyscan -t ed25519 203.0.113.10 2>/dev/null | tee /tmp/kb_known_hosts
ssh-keygen -lf /tmp/kb_known_hosts
# auf dem VPS
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
Nur wenn beide SHA256:…-Fingerprints übereinstimmen, kommt die Zeile aus /tmp/kb_known_hosts als Variable KNOWN_HOSTS in Gitea. Ein Secret ist dafür nicht nötig, der Host-Schlüssel ist öffentlich.
Kleiner Stolperstein im Eingabefeld: Die Zeile ist so lang, dass Gitea sie optisch umbricht. Ob das nur die Anzeige ist oder ein echter Zeilenumbruch, der den Eintrag kaputtmachen würde, sieht man nicht. Mein Trick: Cursor hinter ssh-ed25519, mit Entf alles bis zum AAAA… heranziehen, ein Leerzeichen setzen. Dann ist es garantiert eine Zeile.
Schritt 6: Der Deploy-Workflow
Das Herzstück, .gitea/workflows/deploy.yml:
name: Build & Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
env:
HUGO_VERSION: "0.169.0"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout (inkl. Theme)
uses: actions/checkout@v4
with:
submodules: recursive
- name: Werkzeuge installieren
run: |
apt-get update -qq
apt-get install -y -qq rsync openssh-client
curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" \
| tar -xz -C /usr/local/bin hugo
hugo version
- name: Seite bauen
run: hugo --minify --gc
- name: SSH vorbereiten
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
KNOWN_HOSTS: ${{ vars.KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh-keygen -lf ~/.ssh/known_hosts
- name: Deploy
run: |
rsync -rltcz --delete --chmod=D755,F644 \
-e "ssh -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
public/ deploy@203.0.113.10:
- name: Key entfernen
if: always()
run: rm -f ~/.ssh/deploy_key
Was die Details bewirken:
on: pushaufmain: Jeder Push veröffentlicht. Mitworkflow_dispatchgeht es zusätzlich per Knopfdruck.concurrency: Zwei schnelle Pushes hintereinander deployen nacheinander, nicht gleichzeitig.HUGO_VERSIONfest gepinnt: Dieselbe Version wie auf dem Laptop – keine Überraschungen nach einem Hugo-Update.- Secrets über
env:statt direkt im Skript: Gitea setzt den Wert nicht in den Befehlstext ein, sondern übergibt ihn als Umgebungsvariable. Sonderzeichen können so den Befehl nicht verbiegen. ssh-keygen -lfschreibt den Fingerprint ins Log. So sehe ich bei jedem Lauf, dass der richtige Host-Schlüssel benutzt wird.rsync -rltcz:-cvergleicht Prüfsummen statt Zeitstempel. Hugo erzeugt bei jedem Build neue Dateien, mit-cwerden trotzdem nur wirklich geänderte übertragen.--deleteräumt alte Dateien auf dem Server weg,--chmodsetzt saubere Rechte.if: always(): Der private Schlüssel wird auch dann gelöscht, wenn ein Schritt vorher fehlschlägt.
Gescheitert, Teil 2: Der verschwundene Ordner
Den Test-Workflow wollte ich im selben Zug loswerden und habe zuerst git rm .gitea/workflows/runner-test.yml ausgeführt und danach die neue deploy.yml angelegt. Ergebnis:
bash: .gitea/workflows/deploy.yml: No such file or directory
Git hatte mit der einzigen Datei auch den nun leeren Ordner entfernt. Committet und gepusht wurde nur das Löschen. Ein mkdir -p .gitea/workflows davor, und es lief.
Gescheitert, Teil 3: Der Artikel, der nicht erschien
Als Teil 1 dieser Serie fertig war, habe ich ihn committet, gepusht, die Pipeline lief grün durch – und die Seite lieferte 404.
Erster Verdacht: Timing. Der curl kam vielleicht, bevor das Deployment fertig war. War es aber nicht. Die Pipeline hatte sauber gebaut, denn andere Änderungen aus demselben Commit waren live.
Die Ursache stand in der ersten Zeile, die man immer übersieht: Der Artikel war im Commit noch auf draft: true. Ich hatte ihn zwar auf veröffentlicht umgestellt, im Projekt war aber eine ältere Fassung der Datei gelandet. Und Hugo macht genau, was es soll: Entwürfe werden beim Build einfach übersprungen – ohne Warnung, ohne Fehler, die Pipeline bleibt grün.
Seitdem prüfe ich vor dem Veröffentlichen, was wirklich im Commit steht, und nicht, was ich glaube, gespeichert zu haben:
git show HEAD:content/posts/mein-artikel.md | grep '^draft'
Stolperfallen & Learnings
- Den Runner isolieren. Er führt Code aus und hat über das Docker-Socket Root-Rechte auf seinem Host.
- Eine Firewall-Ausnahme, so schmal wie möglich: eine Quell-IP, ein Ziel, ein Dienst.
- Deploy-Schlüssel mit
restrictundrrsynceinschränken. Dann ist ein gestohlener Schlüssel kein Weg auf den Server. - Host-Schlüssel prüfen statt
StrictHostKeyChecking=no. Einmal den Fingerprint vergleichen, dann ist Ruhe. - Registrierungs-Token nach dem ersten Start entfernen.
wgethängt.1an, wenn die Datei schon existiert. Vor dem Import prüfen.git rmlöscht leere Ordner mit.- Eine grüne Pipeline heißt nicht, dass der Artikel online ist. Hugo überspringt Entwürfe stillschweigend.
Fazit & Ausblick
Die Pipeline ist der Teil, der mir am meisten Spaß macht: Ich schreibe, pushe – und zehn Sekunden später ist der Artikel online. Dabei hat jeder Baustein nur genau die Rechte, die er braucht. Der Runner steht in einem isolierten Netz mit einer einzigen Ausnahme. Der Deploy-Schlüssel kann nur rsync in ein Verzeichnis. Und die Pipeline spricht nur mit einem Server, dessen Schlüssel sie kennt.
Was als Nächstes kommt: In Teil 4 geht es um die Dinge, die eine Website erst „fertig“ machen: eine funktionierende Kontaktadresse (inklusive eines Mailservers, der nur noch im DNS existierte), SPF, DKIM und DMARC, Impressum und Datenschutzerklärung – und ein Favicon.