GitOps ohne Kubernetes: Docker-Deployments mit doco-cd, Renovate und SOPS
Wie ich Docker-Compose-Stacks auf mehreren Hosts aus Git deploye, Secrets verschlüsselt versioniere und Image-Updates kontrolliert automatisiere.
Kubernetes brauche ich in meinem Homelab nicht. Die meisten Dienste bestehen aus einem oder zwei Containern, laufen dauerhaft auf demselben Host und müssen auch nicht automatisch über mehrere Nodes verteilt werden. Was mir trotzdem gefehlt hat, waren nachvollziehbare Deployments.
Lange Zeit lagen meine Compose-Dateien direkt auf den jeweiligen Servern. Ein Update bedeutete:
einloggen, ins richtige Verzeichnis wechseln, docker compose pull ausführen und danach hoffen,
dass ich dieselbe Änderung nicht an einer zweiten Stelle vergessen hatte. Die Konfiguration war
zwar irgendwie reproduzierbar, aber nicht wirklich versioniert. Image-Updates kamen über
verschiedene Mechanismen, Secrets lagen neben den Compose-Dateien und ein Rollback bestand im
Zweifelsfall darin, sich an den vorherigen Stand zu erinnern.
Mein heutiges Deployment-Konzept trennt diese Aufgaben sauber:
- doco-cd setzt den in Git beschriebenen Soll-Zustand auf den Docker-Hosts um.
- Renovate findet neue Image-Versionen und erstellt daraus Pull Requests.
- SOPS verschlüsselt Secrets, damit auch sie versioniert werden können.
- age liefert den vergleichsweise einfachen Schlüsselmechanismus für SOPS.
Die Beispiele in diesem Artikel sind teilweise gekürzt, entsprechen aber dem tatsächlichen Aufbau.
Der Ablauf in einem Bild
Der komplette Deployment-Weg sieht so aus:
Pull Request
Renovate ------------------------------> GitHub
|
| Merge nach master
v
GitHub-Webhooks pro Host
/ | \
v v v
doco-cd doco-cd doco-cd
Host A Host B Host C
| | |
+---- SOPS entschlüsselt ----+
|
v
docker compose up
Git ist dabei die einzige Quelle für den gewünschten Zustand. Renovate ändert keine laufenden
Container. Es ändert nur Dateien im Repository. doco-cd wiederum erfindet keine Updates selbst,
sondern deployt ausschliesslich das, was auf master liegt.
Dieser Unterschied ist wichtig. Ein neues Container-Image wird nicht deshalb produktiv, weil es in einer Registry erschienen ist. Es wird produktiv, weil eine konkrete Änderung im Deployment-Repo gemergt wurde.
Aufbau des Repositories
Das Repository ist nach Hosts und darunter nach Stacks gegliedert:
.
├── .doco-cd.arkham.yaml
├── .doco-cd.innsmouth.yaml
├── .doco-cd.windmill.yaml
├── .sops.yaml
├── renovate.json5
├── .github/
│ └── workflows/
│ └── renovate-automerge-trigger.yml
├── bootstrap/
│ ├── arkham/docker-compose.yaml
│ ├── innsmouth/docker-compose.yaml
│ └── windmill/docker-compose.yaml
├── arkham/
│ ├── caddy/
│ │ ├── docker-compose.yaml
│ │ └── Caddyfile
│ ├── matomo/
│ │ ├── docker-compose.yaml
│ │ └── stack.env
│ └── ...
├── innsmouth/
│ └── ...
├── windmill/
│ └── ...
└── disabled/
└── ...
Ein Verzeichnis unterhalb eines Hosts entspricht genau einem Compose-Projekt. Dadurch kann doco-cd die Stacks automatisch erkennen, ohne dass jeder einzelne noch einmal in einer zentralen Liste gepflegt werden muss.
Im Repository liegen:
- Compose-Dateien
- Anwendungs-Konfigurationen wie Caddyfiles
- verschlüsselte Environment-Dateien und private Konfigurationswerte
- öffentliches Material wie Zertifikatsketten oder Public Keys
Nicht im Repository liegen:
- persistente Anwendungsdaten
- Datenbanken
- der private age-Key
- der GitHub-Zugriffstoken von doco-cd
- das Webhook-Secret
- lokale Registry-Credentials
Die persistenten Daten liegen auf den Hosts konsequent unter
/opt/docker/volumes/<stack>/.... Eine Compose-Datei enthält also beispielsweise:
services:
database:
image: mariadb:11.4
restart: unless-stopped
volumes:
- /opt/docker/volumes/example/database:/var/lib/mysql
env_file:
- stack.env
Das Repository kann damit die Container und ihre Konfiguration wiederherstellen, aber nicht die Daten. GitOps ersetzt kein Backup. Das ist keine Einschränkung von doco-cd, sondern eine bewusst gezogene Grenze.
Eine doco-cd-Instanz pro Docker-Host
Auf jedem Docker-Host läuft eine eigene doco-cd-Instanz. Ich habe mich damit gegen einen zentralen Dienst entschieden, der über mehrere freigegebene Remote-Docker-APIs auf alle Hosts zugreift. Jede Instanz sieht nur den lokalen Docker-Socket und verwaltet nur den eigenen Ausschnitt des Repositories.
Die Host-Konfiguration ist klein. Für arkham sieht sie im Wesentlichen so aus:
name: arkham
working_dir: arkham
reference: master
webhook_filter: '^refs/heads/master$'
auto_discovery:
enabled: true
depth: 1
delete: false
Hinter diesen wenigen Zeilen stecken mehrere wichtige Entscheidungen.
Nur master wird deployt
reference: master legt fest, welcher Stand ausgecheckt wird. Der webhook_filter sorgt zusätzlich
dafür, dass nur Pushes auf master einen Lauf auslösen.
Das ist insbesondere zusammen mit Renovate wichtig. Renovate pusht seine Änderungen zuerst auf eigene Branches. Ohne die feste Referenz und den Filter könnte ein Webhook im schlechtesten Fall einen Renovate-Branch deployen, bevor der Pull Request überhaupt geprüft oder gemergt wurde.
Die Regel lautet deshalb eindeutig:
Branch oder Pull Request = Vorschlag
master = freigegebener Soll-Zustand
Stacks werden automatisch gefunden
auto_discovery.depth: 1 bedeutet, dass doco-cd genau eine Ebene unterhalb von arkham/ scannt.
Jedes Verzeichnis mit einer Compose-Datei wird zu einem eigenen Deployment.
Ein neuer Stack braucht deshalb keinen Eintrag in einer weiteren Konfigurationsdatei. Es genügt, ein neues Verzeichnis anzulegen:
arkham/new-service/docker-compose.yaml
Beim nächsten Lauf erkennt doco-cd den Stack selbst.
Löschen ist absichtlich nicht automatisch
delete: false ist konservativ, aber für mein Homelab die richtige Voreinstellung. Wenn ein
Verzeichnis durch einen fehlerhaften Commit, einen Merge-Konflikt oder eine Umstrukturierung kurz
nicht vorhanden ist, soll doco-cd nicht sofort Container entfernen.
Ausser Betrieb genommene Stacks verschiebe ich nach disabled/. Wegen der Discovery-Tiefe werden sie
dort nicht mehr deployt. Das eigentliche Stoppen und Entfernen ist aber eine bewusste manuelle
Aktion. Auch die Daten unter /opt/docker/volumes bleiben dabei unangetastet.
Damit ist ein Rollback einfach: Verzeichnis zurückschieben, committen und wieder deployen. Die Anwendungsdaten sind noch vorhanden.
Der Bootstrap bleibt ausserhalb der Automatisierung
Irgendetwas muss doco-cd zum ersten Mal starten. Diese Compose-Datei liegt unter bootstrap/<host>
und bewusst nicht im automatisch erkannten Host-Verzeichnis. doco-cd soll sich nicht selbst
re-deployen und dabei den Ast absagen, auf dem es sitzt.
Eine gekürzte Bootstrap-Konfiguration sieht so aus:
services:
doco-cd:
image: ghcr.io/kimdre/doco-cd:0.101.1
container_name: doco-cd
restart: unless-stopped
cap_drop:
- ALL
environment:
TZ: Europe/Zurich
LOG_LEVEL: info
GIT_ACCESS_TOKEN_FILE: /run/secrets/git_token
SOPS_AGE_KEY_FILE: /run/secrets/age_key
WEBHOOK_SECRET_FILE: /run/secrets/webhook_secret
POLL_CONFIG: |
- url: https://github.com/example/docker-deployment.git
reference: master
interval: 3600
target: arkham
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- doco-cd-data:/data
secrets:
- git_token
- age_key
- webhook_secret
secrets:
git_token:
file: /opt/docker/secrets/git_token
age_key:
file: /opt/docker/secrets/age.key
webhook_secret:
file: /opt/docker/secrets/webhook_secret
volumes:
doco-cd-data:
Die Version ist fest gepinnt. Bei einer Komponente, die alle anderen Stacks deployt, möchte ich
nicht durch einen Neustart unbemerkt eine neue latest-Version erhalten.
Die drei Bootstrap-Secrets müssen lokal auf dem Host existieren:
- Ein auf dieses Repository beschränkter GitHub-Token mit Leserechten
- Der private age-Key für die Entschlüsselung
- Das gemeinsame HMAC-Secret für die GitHub-Webhooks
Diese Dateien können nicht sinnvoll mit SOPS aus demselben Repository geladen werden. Ohne Git-Token kann doco-cd das Repository nicht klonen, und ohne age-Key kann es die darin liegenden Secrets nicht entschlüsseln. Dieser kleine manuelle Bootstrap ist die unvermeidbare Ausnahme im ansonsten automatisierten Ablauf.
Der Docker-Socket ist die eigentliche Vertrauensgrenze
Der Mount von /var/run/docker.sock gibt doco-cd praktisch administrative Kontrolle über den
Host. cap_drop: ALL ist trotzdem sinnvoll, ändert an dieser grundsätzlichen Macht des
Docker-Sockets aber nichts.
Die Absicherung muss deshalb an anderen Stellen erfolgen:
- Der Git-Token hat nur lesenden Zugriff auf genau ein Repository.
- Webhooks werden mit einem HMAC-Secret verifiziert.
- Nur
masterdarf deployt werden. - Der Hauptbranch sollte gegen direkte, unbeabsichtigte Änderungen geschützt sein.
- Änderungen am Deployment-Repository sind sicherheitsrelevante Produktionsänderungen.
Ein kompromittiertes Deployment-Repo ist nicht bloss ein Problem für die Dokumentation. Es ist ein kompromittierter Kontrollkanal für alle verwalteten Docker-Hosts.
Webhooks für sofortige Deployments, Polling als Rückversicherung
doco-cd kann das Repository regelmässig pollen oder über einen Webhook gestartet werden. Ich nutze beides.
Ein Push auf master löst für jeden Host einen eigenen GitHub-Webhook aus. Der Reverse Proxy
verteilt die Pfade an die jeweilige doco-cd-Instanz:
POST /v1/webhook/arkham -> doco-cd auf arkham
POST /v1/webhook/innsmouth -> doco-cd auf innsmouth
POST /v1/webhook/windmill -> doco-cd auf windmill
Alle drei Webhooks werden bei einem Push ausgelöst. Jede Instanz gleicht aber nur ihren eigenen Host-Ausschnitt ab. Hat sich dort nichts geändert, bleibt der Lauf ohne Wirkung.
Parallel pollt jede Instanz das Repository in einem deutlich grösseren Intervall. Das ist kein zweiter primärer Deployment-Weg, sondern ein Fallback für einen verpassten Webhook oder eine vorübergehende Netzwerkstörung.
Der Vorteil dieser Kombination ist praktisch: Normalerweise startet das Deployment wenige Sekunden nach dem Merge. Gleichzeitig hängt der Soll-Zustand nicht dauerhaft von der erfolgreichen Zustellung eines einzelnen Webhooks ab.
Bei privaten Container-Registries gibt es noch einen leicht zu übersehenden Punkt: Ein
docker login auf dem Host reicht für einen Container nicht automatisch aus. doco-cd braucht eine
für den Container sichtbare Docker-Konfiguration, beispielsweise über ein read-only gemountetes
DOCKER_CONFIG. Andernfalls kann bereits ein fehlgeschlagener Image-Pull das komplette Deployment
des betroffenen Stacks abbrechen.
SOPS: Secrets gehören ins Repository, aber nicht im Klartext
Die frühere Alternative war, Secrets nur auf den Hosts abzulegen. Das klingt zunächst sicher, hat aber einen erheblichen Nachteil: Die Compose-Datei ist versioniert, ein wesentlicher Teil ihrer Konfiguration nicht. Nach einer Neuinstallation muss man wissen, welche Datei mit welchem Inhalt an welche Stelle gehört.
Mit SOPS liegen auch diese Dateien im Repository. Verschlüsselt wird mit einem age-Empfänger. Der
öffentliche age-Key steht in .sops.yaml, der private Key nur auf den Hosts und in einem separaten
Offline-Backup.
Meine Konfiguration verschlüsselt Environment-Dateien selektiv:
creation_rules:
- path_regex: '\.env$'
encrypted_regex: '(?i)(PASSWORD|PASS|TOKEN|SECRET|APIKEY|HASH|DATABASE_URL)'
age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- path_regex: '.*'
age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Das Ergebnis sieht vereinfacht so aus:
TZ=Europe/Zurich
APP_PORT=8080
DATABASE_USER=example
DATABASE_PASSWORD=ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
API_TOKEN=ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
Timezone, Ports, Hostnamen und Feature-Flags bleiben lesbar. Nur Werte, deren Schlüsselname auf ein
Secret hindeutet, werden verschlüsselt. Dadurch bleiben Pull Requests verständlich. Eine
Änderung von APP_PORT=8080 auf APP_PORT=8081 ist im Diff weiterhin sofort sichtbar.
Für strukturierte YAML-Konfigurationen gibt es eigene Regeln. Dort verschlüssele ich nur konkrete
Leaf-Keys wie client_secret, password, token oder Authorization. Private Keys, PKCS#12-Dateien
oder andere vollständig vertrauliche Dateien werden dagegen komplett verschlüsselt.
Die Reihenfolge der Regeln ist wichtig. Spezifische Regeln müssen vor dem allgemeinen Catch-all stehen.
Secrets bearbeiten
Lokal verweist SOPS auf den privaten age-Key:
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
Danach kann eine verschlüsselte Datei direkt bearbeitet werden:
sops arkham/matomo/stack.env
SOPS öffnet den entschlüsselten Inhalt im Editor und verschlüsselt ihn beim Speichern wieder. Zum reinen Anzeigen reicht:
sops decrypt arkham/matomo/stack.env
Eine neue Datei wird mit der passenden Regel aus .sops.yaml verschlüsselt:
sops --encrypt --in-place arkham/example/stack.env
Nach dem ersten Verschlüsseln lohnt sich immer ein Blick auf git diff. Ein Tippfehler im
Variablennamen kann dazu führen, dass ein geheimer Wert nicht vom encrypted_regex erfasst wird.
DATABASE_PASSWORD wird verschlüsselt, DATABASE_CREDENTIAL in meiner Regel beispielsweise nicht.
Die .sops.yaml-Regeln verschlüsseln Dateien nicht automatisch, nur weil sie im Repository liegen.
Sie bestimmen, was SOPS tut, wenn es für eine Datei aufgerufen wird.
env_file statt Compose-Interpolation
Secrets binde ich in den Compose-Dateien über env_file ein:
services:
app:
image: example/app:1.2.3
env_file:
- stack.env
Ich vermeide dagegen Konstruktionen wie diese:
services:
app:
environment:
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
Damit wird der Secret-Wert nicht durch die ${...}-Interpolation der Compose-Datei geschleust.
Das ist vor allem bei Sonderzeichen wie einem literalen $ im Passwort wichtig. Vertraulich ist
die entschlüsselte Datei danach trotzdem nicht: Auch docker compose config kann Environment-Werte
ausgeben und gehört deshalb nicht ungefiltert in Logs.
Beim Deployment klont doco-cd das Repository, entschlüsselt die SOPS-Dateien im Checkout und ruft
danach Docker Compose auf. Auf dem Host muss deshalb keine zweite, abweichende Kopie von
stack.env gepflegt werden.
Renovate als Update-Motor
Nachdem die Image-Tags in Git liegen, kann Renovate sie auswerten. Das ist der zweite Teil des GitOps-Loops: Renovate findet einen neuen Stand, erstellt einen Pull Request und überlässt die Entscheidung der Repository-Policy.
Die Basis meiner Konfiguration ist überschaubar:
{
extends: [
'config:recommended',
':dependencyDashboard',
':timezone(Europe/Zurich)',
],
automergeSchedule: ['* 1-5 * * 0'],
platformAutomerge: false,
packageRules: [
{
matchUpdateTypes: ['patch', 'pin'],
automerge: true,
},
{
matchUpdateTypes: ['major'],
dependencyDashboardApproval: true,
},
{
matchDatasources: ['docker'],
matchPackageNames: ['postgres', 'docker.io/library/postgres'],
matchUpdateTypes: ['major'],
enabled: false,
},
],
}
Patch-Updates und reine Digest-Pins dürfen automatisch gemergt werden, aber nur in einem Sonntagsfenster zwischen 01:00 und 06:00 Uhr. Minor-Updates bleiben sichtbar und werden manuell geprüft. Major-Updates müssen zuerst im Dependency Dashboard freigegeben werden.
PostgreSQL-Major-Updates sind komplett deaktiviert. Ein Sprung von PostgreSQL 16 auf 17 ist keine normale Image-Aktualisierung, sondern eine Datenbankmigration. Ein automatischer Tag-Wechsel würde bestenfalls mit einem nicht startenden Container und schlechtestenfalls mit einem unklaren Datenzustand enden.
Die Regeln bilden damit nicht nur Versionsnummern ab, sondern das Risiko der jeweiligen Änderung.
Nicht jedes Docker-Tag ist SemVer
Bei Docker-Images trifft Renovate auf sehr unterschiedliche Tag-Schemata. Manche Images nutzen
klassisches SemVer, andere ergänzen Build-Nummern, Git-Hashes oder Architektur-Suffixe. Für solche
Images habe ich eigene versioning-Regeln hinterlegt.
Ein Beispiel für ein Image mit einem Datum beziehungsweise einer fortlaufenden Build-Nummer:
{
matchPackageNames: ['jellyfin/jellyfin'],
versioning: 'regex:^(?<major>\\d{10})-amd64$',
}
Auch rollende Tags wie latest oder main sind ein Sonderfall. Der Tag selbst ändert sich nicht,
obwohl ein neues Image dahinterliegt. Dort aktiviere ich Digest-Pinning:
{
matchDatasources: ['docker'],
matchPackageNames: ['ghcr.io/windmill-labs/**'],
pinDigests: true,
groupName: 'windmill',
}
Aus
image: ghcr.io/example/service:latest
wird damit:
image: ghcr.io/example/service:latest@sha256:0123456789abcdef...
Das Deployment ist dadurch reproduzierbar. latest ist nur noch die menschlich lesbare
Bezeichnung; entscheidend ist der unveränderliche Digest. Wenn sich der Digest hinter dem Tag
ändert, kann Renovate einen normalen Pull Request erstellen.
Bei privaten Registries braucht Renovate eigene Leserechte. Der Zugriffstoken liegt dabei nicht im
Klartext in renovate.json5, sondern wird mit dem von Renovate vorgesehenen Repository-spezifischen
Verschlüsselungsverfahren in einer hostRules-Regel hinterlegt.
Gruppen brauchen eine eigene Risikoklasse
Images desselben Anbieters in einem Pull Request zusammenzufassen reduziert die Anzahl der PRs. Ich gruppiere beispielsweise LinuxServer-Images. Patch- und Minor-Updates landen aber absichtlich in getrennten Gruppen.
Der Grund ist Renovates Automerge-Logik: Ein Gruppen-PR wird nur automatisch gemergt, wenn jedes enthaltene Update automerge-fähig ist. Ein einziges Minor-Update kann sonst mehrere unkritische Patch-Updates im selben Branch blockieren.
Die Regeln sehen deshalb getrennt aus:
{
matchDatasources: ['docker'],
matchPackageNames: ['lscr.io/linuxserver/**'],
matchUpdateTypes: ['patch', 'pin'],
groupName: 'linuxserver patch',
},
{
matchDatasources: ['docker'],
matchPackageNames: ['lscr.io/linuxserver/**'],
matchUpdateTypes: ['minor', 'major'],
groupName: 'linuxserver minor',
},
So bleibt der Komfort der Gruppierung erhalten, ohne dass ein grösseres Update die kleineren Sicherheits- und Bugfix-Releases ausbremst.
Direkt exponierte Kerndienste wie der Reverse Proxy oder der Identity Provider halte ich bewusst aus grossen Sammel-PRs heraus. Deren Release Notes und mögliche Auswirkungen möchte ich einzeln sehen.
Das Automerge-Zeitfenster braucht auch einen Renovate-Lauf
Eine kleine Falle zeigte sich erst im Betrieb: automergeSchedule bedeutet nicht, dass Renovate zu
dieser Zeit automatisch gestartet wird. Es bedeutet nur, dass ein ohnehin laufender Renovate-Job in
diesem Fenster mergen darf.
Der Scheduler der gehosteten Renovate-App traf mein ursprünglich enges Sonntagsfenster über Wochen kein einziges Mal. Die Pull Requests waren automerge-fähig, wurden aber nie gemergt.
Deshalb gibt es im Repository einen kleinen GitHub-Actions-Workflow. Er läuft sonntags innerhalb des Automerge-Fensters und aktiviert die manuelle Job-Checkbox im Renovate Dependency Dashboard. Dadurch wird ein Renovate-Lauf angefordert. Das Fenster ist bewusst mehrere Stunden breit, weil weder GitHub-Cron noch die Warteschlange der gehosteten App sekundengenau arbeiten.
Das ist kein grundsätzliches Renovate-Problem, aber ein gutes Beispiel dafür, dass Zeitpläne immer zwei Seiten haben:
Wann darf Renovate mergen?
Wann läuft Renovate tatsächlich?
Beides muss zusammenpassen.
Eigene Images müssen nicht auf Renovate warten
Für Images aus fremden Projekten ist Renovate der richtige Mechanismus. Bei selbst gebauten Images ist ein direkterer Weg oft sinnvoller.
Mein Blog-Repository baut bei einer Änderung selbst ein neues Image und pusht es in die Registry. Der CI-Job kennt danach den exakten Manifest-Digest. Statt auf den nächsten Renovate-Lauf zu warten, schreibt die CI diesen Digest direkt in die Compose-Datei des Deployment-Repositories und committet die Änderung:
Push im Anwendungs-Repo
-> Image bauen
-> Image pushen
-> exakten Digest ins Deployment-Repo schreiben
-> Push auf master
-> doco-cd deployt per Webhook
Renovate bleibt für dieses Image als Rückversicherung konfiguriert. Der normale Deployment-Weg ist aber die CI des Projekts selbst. Das ist schneller und verknüpft einen bestimmten Commit direkt mit einem bestimmten Image-Digest.
GitOps bedeutet nicht, dass jede Änderung von Renovate kommen muss. Entscheidend ist, dass auch der direkte CI-Weg wieder eine nachvollziehbare Änderung am Soll-Zustand in Git erzeugt.
Von manuellen Compose-Verzeichnissen zu doco-cd
Die Umstellung eines bestehenden Hosts sollte nicht mit aktivierter Auto-Discovery für alle Stacks beginnen. doco-cd würde sonst sofort versuchen, jeden gefundenen Stack aus dem neuen Checkout zu deployen, obwohl Datenpfade oder Konfigurationsdateien noch nicht migriert sind.
Ich habe deshalb zuerst einzelne Pilot-Stacks explizit angegeben:
POLL_CONFIG: |
- url: https://github.com/example/docker-deployment.git
reference: master
interval: 180
deployments:
- name: homarr
working_dir: arkham/homarr
- name: sillytavern
working_dir: arkham/sillytavern
Jeder bestehende Stack wurde danach einzeln umgestellt. Die Reihenfolge ist wichtig:
docker compose -f /opt/docker/compose/example/docker-compose.yaml down
mkdir -p /opt/docker/volumes
mv /opt/docker/example /opt/docker/volumes/example
# danach doco-cd den Stack deployen lassen
Erst stoppen, dann Daten verschieben, dann deployen. Wird der neue Stack zu früh gestartet, legt Docker fehlende Bind-Mount-Verzeichnisse leer an. Danach ist nicht mehr auf den ersten Blick klar, welches Verzeichnis die echten Daten enthält.
Erst nachdem alle Stacks eines Hosts migriert waren, habe ich von der expliziten Liste auf
target: arkham und damit auf die Auto-Discovery umgestellt.
Was nach dem Update noch übrig bleibt
Ein automatisiertes Deployment aktualisiert Container, räumt aber nicht zwingend alte Images auf. Das fiel bei mir nach der Ablösung des vorherigen Update-Dienstes auf: Nach jedem Tag-Update blieb das alte, weiterhin getaggte Image auf dem Host liegen.
Ein normales docker image prune entfernt nur ungenutzte dangling Images. Alte Images mit einem
weiterhin vorhandenen Tag bleiben bestehen. Deshalb läuft auf jedem Host ein kleiner Prune-Stack:
docker image prune -af \
--filter until=24h \
--filter 'label!=prune=never'
docker builder prune -af --filter until=168h
Das Zeitfenster schützt frisch gebaute Images. Das Label prune=never schützt lokale Images, die
nicht aus einer Registry zurückgeholt, sondern nur erneut gebaut werden können. Der BuildKit-Store
wird separat bereinigt, weil er vom Image-Prune nicht erfasst wird.
Gerade prune -a sollte nicht unbesehen kopiert werden. Wer lokale Images baut oder bewusst
ungenutzte Images für einen schnellen Rollback vorhalten will, braucht passende Ausnahmen.
Wie ein normales Update abläuft
Ein typisches Patch-Update durchläuft heute folgende Schritte:
- Renovate erkennt einen neuen Image-Tag oder Digest.
- Renovate erstellt einen Pull Request mit der Änderung an der Compose-Datei.
- Ein Patch- oder Pin-Update wird im Sonntagsfenster automatisch gemergt. Grössere Updates bleiben zur manuellen Prüfung offen.
- Der Push auf
masterlöst die Host-Webhooks aus. - Die zuständige doco-cd-Instanz klont den aktuellen Stand von
master. - doco-cd entschlüsselt die SOPS-Dateien mit dem lokalen age-Key.
- Docker Compose zieht das neue Image und erstellt die betroffenen Container neu.
- Die persistenten Daten bleiben in den Bind-Mounts auf dem Host erhalten.
Der komplette Weg ist in Git sichtbar: Welche Version wurde vorgeschlagen, welcher Pull Request hat sie eingeführt und welcher Commit wurde deployt?
Ein Rollback beginnt mit git revert. Der daraus entstehende Push startet denselben Deployment-Weg
in die andere Richtung. Das funktioniert gut für Container-Konfigurationen und Image-Versionen,
aber nicht automatisch für Datenbankmigrationen. Hat eine neue Anwendungsversion das Datenformat
verändert, braucht auch der Rollback einen passenden Plan für die Daten.
Ebenso ist ein Compose-Recreate kein Rolling Deployment. Je nach Dienst entsteht beim Austausch des Containers eine kurze Unterbrechung. Für mein Homelab ist das akzeptabel. Wer unterbrechungsfreie Deployments oder automatische Verteilung über mehrere Nodes benötigt, kommt irgendwann in einen Bereich, für den ein Orchestrator tatsächlich sinnvoller ist.
Warum dieses Konzept für mich funktioniert
Die Lösung ist deutlich kleiner als eine Kubernetes-Installation, bietet aber genau die Eigenschaften, die mir bei manuell gepflegten Compose-Verzeichnissen gefehlt haben:
- Der laufende Zustand lässt sich aus Git herleiten.
- Änderungen sind als Commits und Pull Requests nachvollziehbar.
- Secrets sind versioniert, ohne im Klartext im Repository zu liegen.
- Updates werden automatisch gefunden, aber nach Risiko unterschiedlich behandelt.
- Ein Merge ist gleichzeitig die bewusste Freigabe für das Deployment.
- Jeder Host verwaltet nur seinen eigenen Ausschnitt.
- Webhooks sorgen für schnelle Reaktion, Polling verhindert dauerhafte Abweichungen.
Der wichtigste Teil ist dabei nicht eines der drei Werkzeuge für sich. Entscheidend sind die klaren Zuständigkeiten:
doco-cd deployt den freigegebenen Soll-Zustand.
Renovate schlägt neue Versionen vor.
SOPS macht Secrets versionierbar.
Git verbindet alles und führt die Historie.
Damit ist aus einer Sammlung von Docker-Compose-Dateien ein überschaubares GitOps-System geworden. Nicht maximal abstrakt und nicht für jeden denkbaren Infrastrukturfall gebaut, aber für mehrere feste Docker-Hosts sehr gut nachvollziehbar, wartbar und im Fehlerfall erstaunlich einfach zu reparieren.