GitOps ohne Komodo: Webhook-basiertes Auto-Deployment für Docker Compose
Wer meinen letzten Beitrag zum Homelab Setup 2025 gelesen hat, weiß dass ich dort Komodo als GitOps-Werkzeug für meine Docker Compose Stacks einsetze. Das Prinzip dahinter ist gut: Git als Single Source of Truth, Renovate für automatische Version-Updates, und bei einem neuen Commit deployed Komodo die Änderungen auf das Zielsystem. Funktioniert im Grunde auch – aber Komodo hat mich mit einer Sache regelmäßig zur Weißglut gebracht: File Permissions. Das funktioniert einfach nicht konsistent. Entweder stimmen die Berechtigungen auf dem Host nicht, oder Komodo meckert beim Deployment, oder irgendwas dazwischen. Nach dem x-ten Mal hatte ich genug und wollte eine schlankere Lösung.
Die Anforderung ist dabei überschaubar: Wenn Renovate einen neuen Commit in das Repo pusht (nach meinem Merge des Pull Requests), soll die compose-Datei auf dem Server aktualisiert und die betroffenen Container neu gestartet werden. Kein Kubernetes, kein weiteres Framework. Einfach git pull und docker compose up.
Das Grundprinzip
Das Setup basiert auf drei Komponenten die zusammenspielen:
- Gitea – mein selbst gehosteter Git-Server, auf dem die Compose-Dateien liegen
- Renovate – überprüft alle vier Stunden per Cronjob die verwendeten Image-Tags gegen die jeweiligen Container-Registries und öffnet bei neuen Versionen automatisch Pull Requests. Ich merge den PR, der Commit landet auf
main. - webhook – ein einzelnes Go-Binary, das auf dem Zielserver einen HTTP-Endpunkt bereitstellt. Gitea feuert bei jedem Push einen Webhook dorthin, woraufhin das Deploy-Script ausgeführt wird.
Der Datenfluss sieht so aus:
Renovate erkennt neues Image-Tag
→ PR in Gitea
→ Merge
→ Gitea feuert Webhook
→ webhook-Binary ruft deploy.sh auf
→ git pull + docker compose up -dKein Polling, kein Daemon der irgendwelche Container-Registries beobachtet, kein Framework das eigene Meinungen zu File Permissions hat.
Warum nicht einfach Watchtower oder ein Cron-Script?
Bevor ich zu webhook gegriffen habe, hatte ich kurz zwei andere Ansätze im Kopf.
Watchtower überwacht laufende Container und zieht neue Images automatisch. Das Problem: Watchtower reagiert auf neue Images in der Registry, nicht auf Git-Commits. Wenn Renovate einen PR öffnet und ich den merge, hat Watchtower davon keine Ahnung – der zieht einfach irgendwann wenn er selbst nachschaut. Das passt nicht zu einem Setup bei dem Git die einzige Quelle der Wahrheit sein soll. Außerdem will ich den Merge-Zeitpunkt kontrollieren, nicht Watchtower.
Cron-Script mit git fetch wäre die andere Möglichkeit – alle paar Minuten nachschauen ob es Änderungen gibt und dann deployen. Funktioniert, hat aber einen konzeptionellen Nachteil: Es ist Polling. Ich baue mit Gitea bereits eine Infrastruktur auf die Push-Events liefern kann, die sollte ich auch nutzen. Ein Webhook reagiert innerhalb von Sekunden, ein Cron-Job mit 5-Minuten-Intervall verzögert unnötig.
webhook ist der sauberste Weg: Event-getrieben, kein Extra-Daemon, ein einzelnes Binary ohne Abhängigkeiten.
Repo-Struktur
Ich habe pro Server ein eigenes Repository:
apps-stackfür dendocker01– dort laufen Tools wie Paperless-ngx, Immich, Vaultwarden und ähnlichesmedia-stackfür denmedia01– dort leben alle*arr-Container und was sonst noch zum Medien-Stack gehört
Jedes Repo enthält einfach die compose.yaml sowie ggf. weitere Konfigurationsdateien die einzelne Anwendungen benötigen. Nichts Besonderes.
Renovate in den Repos
Renovate läuft selfhosted und überprüft per Cronjob alle vier Stunden die Image-Tags in den Compose-Dateien. Dafür reicht eine minimale renovate.json im Repo-Root:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"docker-compose": {
"enabled": true
}
}Renovate erkennt dann automatisch die Image-Tags in der Compose-Datei und öffnet PRs wenn eine neuere Version verfügbar ist. Bei Software die semantische Versionierung verwendet (also major.minor.patch) lässt sich das besonders fein steuern – z. B. nur Patch-Updates automatisch mergen, bei Minor-Updates erst schauen was sich geändert hat.
Für Postgres-Container würde ich Renovate übrigens ausschließen. Major-Upgrades zwischen Postgres-Versionen erfordern einen manuellen Datenbankdump und -import, das soll mir kein Bot automatisch reinschießen.
webhook installieren und konfigurieren
webhook gibt es als einzelnes Binary, alternativ auch als Paket:
apt install webhookDie Konfiguration liegt unter /etc/webhook/webhook.conf:
- id: deploy
execute-command: /opt/scripts/deploy.sh
command-working-directory: /opt/media-stack
trigger-rule:
match:
type: payload-hmac-sha256
secret: "{{getenv \"WEBHOOK_SECRET\"}}"
parameter:
source: header
name: X-Gitea-SignatureEin paar Anmerkungen dazu:
Header-Name: Gitea schickt die Signatur als X-Gitea-Signature – ohne -256-Suffix und ohne sha256=-Präfix im Wert. Das klingt trivial, hat mich aber beim ersten Versuch einen Debugging-Durchgang gekostet.
Secret als Umgebungsvariable: Das HMAC-Secret gehört nicht im Klartext in die Konfigurationsdatei, erst recht nicht wenn die Datei im Repo versioniert ist. Deshalb wird es per getenv aus der Umgebung gelesen.
Das Secret generieren:
openssl rand -hex 32Und in eine Secrets-Datei schreiben die nur root lesen kann:
# /etc/webhook/secrets.env
WEBHOOK_SECRET=dein-generierter-stringchmod 600 /etc/webhook/secrets.envSystemd-Unit
# /etc/systemd/system/webhook.service
[Unit]
Description=Webhook Receiver
After=network.target
[Service]
User=teqqy # Hier den eigenen Usernamen eintragen
EnvironmentFile=/etc/webhook/secrets.env
ExecStart=/usr/bin/webhook -hooks /etc/webhook/webhook.conf -port 9000
Restart=on-failure
[Install]
WantedBy=multi-user.targetUser=teqqy sorgt dafür dass der webhook-Daemon und damit auch das Deploy-Script nicht als root laufen. Das ist mir wichtig – Prozesse die nur git pull und docker compose up ausführen müssen haben nichts als root verloren. Den Usernamen natürlich durch den eigenen ersetzen; der User braucht Zugriff auf das Repo-Verzeichnis und muss Mitglied der docker-Gruppe sein.
EnvironmentFile lädt die secrets.env und stellt WEBHOOK_SECRET als Umgebungsvariable bereit, bevor der Prozess startet – so kommt das Secret sauber in den getenv-Aufruf der Konfiguration. Restart=on-failure sorgt dafür dass der Daemon bei einem unerwarteten Absturz automatisch neu startet, ohne dass man manuell eingreifen muss.
systemctl enable --now webhookGitea Webhook einrichten
In Gitea unter Repository → Einstellungen → Webhooks → Webhook hinzufügen → Gitea:
- Ziel-URL:
http://media01.example.com:9000/hooks/deploy - Secret: denselben String den du oben generiert hast
- Trigger: Nur
Push-Events reichen aus
Gitea berechnet bei jedem Push automatisch eine HMAC-SHA256-Signatur über den Request-Body und schickt sie als X-Gitea-Signature-Header mit. Das webhook-Binary verifiziert die Signatur bevor es das Script ausführt – ein simpler Authorization-Header wäre hier deutlich unsicherer, weil man den Payload damit nicht gegen Manipulation absichert.
Port 9000 sollte nicht direkt ins Internet exponiert sein. In meinem Setup sind Gitea und der webhook-Daemon im selben internen Netz, der Port ist von außen nicht erreichbar. Wer den Endpunkt für eine externe Gitea-Instanz oder GitHub erreichbar machen muss, sollte das zumindest hinter einem Reverse Proxy mit IP-Beschränkung tun.
Das Deploy-Script
#!/bin/bash
set -euo pipefail
REPO=/opt/media-stack
LOG=/opt/deploy-logs/deploy-$(date +%Y%m%d-%H%M%S).log
exec >> "$LOG" 2>&1
echo "=== Deploy $(date) ==="
cd "$REPO"
git pull origin main
docker compose up -d --remove-orphans
docker compose ps
echo "=== Done ==="set -euo pipefail sorgt dafür dass das Script bei jedem Fehler sofort abbricht und einen Exit-Code ≠ 0 zurückgibt. Den loggt webhook dann ebenfalls, sodass journalctl -u webhook bei Problemen der erste Anlaufpunkt ist.
Das Log-Verzeichnis muss dem ausführenden User gehören:
mkdir -p /opt/deploy-logs
chown teqqy:teqqy /opt/deploy-logsStolpersteine die ich unterwegs getroffen habe
.git-Verzeichnis Ownership: Wenn das Repo irgendwann mal als anderer User geklont oder gepullt wurde, verweigert Git den Zugriff mit einer Warnung zu “dubiosen Besitzverhältnissen”. Fix:
chown -R teqqy:teqqy /opt/media-stack/.gitsafe.directory: In manchen Setups hilft zusätzlich:
git config --global --add safe.directory /opt/media-stack/var/log Permissions: Der ausführende User hat dort keine Schreibrechte. Deshalb das Log-Verzeichnis in einen Pfad legen der dem User gehört – ich nutze /opt/deploy-logs.
*arr-Container und UID/GID: Meine *arr-Container laufen mit 99:100 (Unraid-kompatibel wegen NFS). Beim allerersten Deployment legt Docker die Bind-Mount-Verzeichnisse mit 1000:1000 an wenn sie noch nicht existieren. Einmalig vor dem ersten Start:
chown -R 99:100 /mnt/data/arr/Danach ist das kein Thema mehr da die Verzeichnisse bereits existieren.
Monitoring
Ich setze Uptime Kuma ein und beobachte dort ob die Container nach einem Deployment noch sauber laufen. Das reicht für meinen Use Case: Wenn ein Container nach einem fehlgeschlagenen Update nicht mehr antwortet, schlägt Uptime Kuma an. Das deploy.log und journalctl -u webhook liefern dann die Details warum es schiefgelaufen ist.
Ein Dead-Man’s-Switch (z. B. über Healthchecks.io) wäre der nächste sinnvolle Schritt wenn man auch merken will wenn ein Deploy einfach ausbleibt – aber für einen Media-Stack ist das für mich nicht kritisch genug um extra Aufwand zu treiben.
Fazit
Das Setup ist jetzt seit kurzem in Betrieb und macht genau was es soll – ohne dass ich irgendwas an Komodo debuggen muss. Das webhook-Binary ist stabil, der Deployment-Flow ist deterministisch, und der Gesamtoverhead beschränkt sich auf ein paar Konfigurationsdateien und ein Shell-Script.
Wer ähnliche Anforderungen hat und kein Kubernetes betreiben möchte: Das ist meiner Meinung nach der pragmatischste Weg. Keine Magie, keine Frameworks, kein Overengineering. Nur git pull und docker compose up.
Bei Fragen oder Anmerkungen erreichst du mich über die Links in der Menüleiste.