Der Todesstern wird abgeschaltet. 36 Container müssen raus, drei neue Basen stehen bereit. Klingt nach Plan — bis der Health-Monitor deine abgeschalteten Container löscht, ein DNS-Cutover stille Leichen aufdeckt, und du feststellst, dass dein Vaultwarden seit Wochen keine Push-Benachrichtigungen mehr verschickt.
Hier ist der komplette Bericht der Evakuierung — inklusive aller Fallstricke, stillen Ausfälle und dem Konzept, das die Migration gerettet hat: die Aufrufer-Karte.
Die Ausgangslage
Der Docker-Host todesstern (192.168.66.10, LXC unter Proxmox) war lange die Kommandozentrale der Self-Hosting-Flotte. 36 Container, eine wilde Mischung aus Apps, Infrastruktur und KI-Tools. Aber die Disk war am Ende:
/dev/sda 120G 118G 2.2G 98% /
Kein Speicher für Updates, keine Logs, keine neuen Images. Der Container wuchs nicht mehr. Gleichzeitig war die Architektur überfällig: alles auf einem Host ist ein Single Point of Failure. Zeit für eine Verteilung auf mehrere dedizierte Systeme.
Der Plan
Drei Phasen, drei Ziel-Hosts, ein 14-Tage-Fenster mit 2 Tagen Rollback-Option:
| Phase | Ziel | Host | IP | Container |
|---|---|---|---|---|
| A | Apps | echo-basis | 192.168.66.67 | Firefly III, n8n, Mealie, Jotty (8 Container) |
| B | Infrastruktur | executor | 192.168.66.66 | Keycloak, Vaultwarden, step-ca (5 Container) |
| C | KI-Stack | droide-r2 | 192.168.66.62 | Ollama, Whisper-STT, Wyoming (3 Container) |
Jeder Service bekommt seine eigene Compose-Datei, eigenes Datenverzeichnis, eigenen Port. Der Reverse-Proxy auf tantive-iv (192.168.66.1) zeigt per DNS-Name auf die neue IP. Der Cutover: DNS A-Record ändern, fertig.
So war der Plan. Die Realität sah anders aus.
Phase 0: Das Aufräumen vor dem Aufräumen
Bevor die erste Migration startete, musste Platz geschaffen werden — auf der Disk und in der Planung.
Image Prune und Volume Cleanup
# Alte, ungenutzte Images entfernen
docker image prune -a --filter "until=48h"
# Orphaned Volumes (Container gelöscht, Volume vergessen)
docker volume prune -f
Ergebnis: 13,5 GB durch Image Prune, 4,2 GB durch orphaned Volumes.
Alte Service-Verzeichnisse
Über die Jahre hatten sich Datenverzeichnisse von längst decommissionten Services angesammelt — alte Postgres-DBs, Media-Archiv-Reste, Test-Instanzen. Ein Gang durch /opt förderte ~33 GB zutage:
du -sh /opt/* | sort -rh | head -20
Nach dem Cleanup: 74% Disk statt 98%. Fast 50 GB freigegeben, ohne die Migration selbst zu berühren.
⚠️ Lesson 1: Der Health-Monitor, der deine Container frisst
Ein kritischer Fund: Auf dem Todesstern lief ein Cron-Job als Health-Monitor:
# Alle 5 Minuten: Container die EXITED sind → löschen
docker rm -f $(docker ps -a --filter "status=exited" -q) 2>/dev/null
Die Idee war gut — tote Container automatisch entsorgen, damit die docker ps-Liste sauber bleibt. In der Praxis bedeutete das:
- Du stoppst einen Container für die Migration → Container wechselt zu
EXITED - 5 Minuten später:
docker rm -flöscht ihn - Dein Rollback-Plan “einfach
docker startausführen” ist tot
Fix Phase A:
# Temporär: Health-Monitor pausieren
crontab -e # Zeile auskommentieren
Danach: Rollback-Plan geändert von docker start auf docker compose up — solange das Compose-File und die Images noch da sind, geht das. Aber die Panik beim ersten Fehlschlag war real.
Für Phase B und C: Der Cron war von Anfang an deaktiviert.
Phase A: Apps auf die Echo-Basis
Acht Container mussten auf echo-basis (192.168.66.67): Firefly III (Finanzen), n8n (Automation), Mealie (Rezepte), Jotty (Wissen).
Bit-identische Images via docker save/load
Das Wichtigste bei einer Migration: Keine :latest-Überraschungen. Wenn du auf dem Ziel-Host docker compose pull machst, bekommst du vielleicht eine andere Version. Deshalb:
# Source: Images exportieren
docker save fireflyiii/core:v6.1.5 | gzip > /tmp/firefly.tar.gz
docker save n8nio/n8n:1.83.0 | gzip > /tmp/n8n.tar.gz
# Ziel: Images laden (bit-identisch)
gunzip -c /tmp/firefly.tar.gz | docker load
gunzip -c /tmp/n8n.tar.gz | docker load
Daten via rsync
Jeder Service gestoppt, Daten synchronisiert:
docker compose down
rsync -av --delete /opt/firefly-iii/ root@echo-basis:/opt/firefly-iii/
Downtime pro Service: ~5 Minuten. Nicht ideal für User, aber akzeptabel für ein Homelab.
Cutover per DNS — eine verdammt gute Idee
Statt Configs auf dem Reverse-Proxy oder in den Apps zu ändern: DNS A-Record umbiegen. Ein einziger Punkt der Veränderung:
firefly.rebellion-base.net A 192.168.66.10 → 192.168.66.67
mealie.rebellion-base.net A 192.168.66.10 → 192.168.66.67
flow.rebellion-base.net A 192.168.66.10 → 192.168.66.67
Kein NGINX-Edit, kein Port-Rerouting, keine vergessenen Referenzen. Der DNS-Cache muss nur abwarten (TTL war vorher auf 60s gesenkt).
🔴 Gefundene Fallstricke
1. Hardcodierte IPs — überall
Zwei Funde:
# Alertmanager config — hatte die alte IP hart drin
receivers:
- name: 'n8n-webhook'
webhook_configs:
- url: 'http://192.168.66.10:8098/webhook/zabbix-alert'
→ Fix: http://flow.rebellion-base.local:8098/webhook/zabbix-alert
Ein Zammad-Webhook in der Postgres-DB zeigte ebenfalls auf die alte IP. Nur durch aktives Suchen gefunden.
2. Drei tote n8n-Workflows (seit April kaputt)
Der n8n-Debug förderte ein Problem zutage: Drei IMAP-basierte Workflows zeigten “success” an — aber machten nichts. Die Ursache: Sie riefen eine Nextcloud-API auf einer IP auf, die seit einem Umzug im April nicht mehr existierte.
Der Workflow loggte keinen Fehler, weil der n8n-Node-Fehler in der Konfiguration still geschluckt wurde (continueOnFail: true). Seit April liefen die Workflows ins Leere. Niemand hat es gemerkt.
Lesson: “Success” in n8n heißt nur “der Workflow ist durchgelaufen”, nicht “er hat was Nützliches getan.”
3. Plausible lief ohne Datenbank — seit Wochen
plausible_db (Postgres für das Analytics-Dashboard) war im EXITED-Status. Der Health-Monitor-Cron hatte ihn gelöscht. Plausible selbst lief noch — aber ohne Datenbank. Kein Dashboard-Zugriff, keine Seitenaufrufe erfasst. Für Wochen.
Niemand hat auf das Dashboard geschaut.
Phase B: Infrastruktur auf den Executor
Fünf Container auf executor (192.168.66.66): Keycloak (Identity & Access Management), Vaultwarden (Passwort-Manager), step-ca (eigene CA). Die kritischste Phase — wenn hier was schiefgeht, ist SSO kaputt, Passwörter unerreichbar, Zertifikate nicht ausstellbar.
Keycloak: Wer hängt eigentlich dran?
Bevor ein Identity Provider umzieht, musst du wissen, wer ihn nutzt. Also: Keycloak-Datenbank abgefragt:
SELECT c.client_id, c.name, c.description
FROM keycloak.client c
WHERE c.enabled = 'true';
Ergebnis: 9 OAuth2/OIDC-Clients — Gitea, Nextcloud, grommunio, Mealie, Jotty, Vaultwarden, und drei interne Dienste. Alle nutzen auth.rebellion-base.net als Endpoint (in jeder App-Konfiguration verifiziert).
Goldene Regel für SSO-Migration: Du musst vor dem Umzug wissen, wer den Dienst ruft. Nichts ist peinlicher als nach dem Cutover ein “502 — Keycloak not found” von sechs Apps gleichzeitig.
step-ca: 30 Tage Log-Check
Die eigene Certificate Authority (ca.rebellion.local) hatte laut Logs seit Wochen fast null Aktivität:
journalctl -u step-ca --since "30 days ago" | grep "incoming" | wc -l
# → 3
Drei ACME-Zertifikatsanfragen in 30 Tagen. Das bedeutete: Kein aktiver ACME-Client nutzt die CA regelmäßig — oder die Clients haben ein Problem. Nachtrag: Ein Fund in einer schlafenden Konfiguration war noch eine hartcodierte IP (192.168.66.10:8443 statt ca.rebellion.local:443). Fix: DNS-Name statt IP.
Cutover-Schritt für Schritt
# 1. Service stoppen
docker compose down
# 2. Daten delta-synchronisieren
rsync -av --delete /opt/step-ca/ root@executor:/opt/step-ca/
# 3. Auf Ziel-Host starten (noch auf der alten DNS-IP)
docker compose up -d
# 4. Pre-Test: Gegen neue IP prüfen
curl -I http://192.168.66.66:8080/auth/realms/master/.well-known/openid-configuration
# 5. DNS umbiegen
# A-Record: auth.rebellion-base.net → 192.168.66.66
# 6. Verifizieren: vom AUFRUFER aus prüfen, nicht vom Service selbst
docker exec gitea curl -I https://auth.rebellion-base.net/realms/master/.well-known/openid-configuration
🔴 Vaultwarden: Push war seit Monaten tot
Das Highlight der Phase B: Vaultwarden-Push-Benachrichtigungen (dass Clients in Echtzeit erfahren, “Passwort geändert, bitte sync”) funktionierten nicht.
Die Ursache lag im NGINX-Reverse-Proxy (pwm.conf):
# Alter, kaputter Config-Ausschnitt
location /notifications/hub {
proxy_pass http://192.168.66.10:3012;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Vaultwarden hatte Port 3012 in Version 1.31 entfernt — wir liefen 1.35.3. Der WebSocket-Endpunkt existierte nicht mehr. Seit dem Upgrade (Monate her) war jede Push-Benachrichtigung ins Leere gelaufen.
Fix: WebSocket direkt auf den Haupt-Port (8081) routen, mit korrektem Upgrade-Handling:
location /notifications/hub {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /notifications/hub/negotiate {
proxy_pass http://127.0.0.1:8081;
}
# Cache aus: Push nie cachen
proxy_cache_bypass $http_upgrade;
Danach: bw sync vom Client — Push funktioniert wieder.
Phase C: KI-Stack auf die Droide-R2
Ollama (LLMs), Whisper-STT (Spracherkennung), Wyoming (Sprach-Assistent) — drei Container auf droide-r2 (192.168.66.62).
Port-Kollision: Whisper vs. Qdrant
# Whisper-STT wollte Port 8102
docker compose up # → Error: port 8102 already allocated
Qdrant (Vektor-Datenbank) war schon auf droide-r2 und belegte Port 8102. Fix:
# docker-compose.yml für whisper-stt
services:
whisper:
ports:
- "8103:10300" # 8102 war belegt → 8103
Danach: NGINX-Config aktualisiert (proxy_pass http://droide-r2:8103).
Ollama-Modelle via rsync
1,8 GB Modelle (llama3.2, mistral, nomic-embed-text) — zu groß zum Neuladen:
rsync -av /opt/ollama/models/ root@droide-r2:/opt/ollama/models/
Danach: ollama list zeigt alle Modelle, kein Download nötig.
🚨 Stiller Ausfall #1: Home Assistant Voice — seit April tot
Der Wyoming-Container dient als Sprach-Assistent für Home Assistant. Aber HA konnte ihn nicht erreichen. HA läuft im Home-LAN (192.168.1.x), die Docker-Hosts im Rechenzentrums-LAN (192.168.66.x). Dazwischen: die Firewall imperial-barriere.
Diagnose: Ein VLAN-Routing-Problem war die Ursache. Eine Firewall-Regel leitete den gesamten internen Traffic von HA zum Rechenzentrum durch den VPN-Tunnel. Sobald der VPN weg war (was häufig vorkam), funktionierte nichts mehr.
HA (192.168.1.x) → Firewall → VPN-Tunnel → ... → 192.168.66.x (tot wenn VPN down)
HA (192.168.1.x) → Firewall → Direktroute → 192.168.66.x (funktioniert)
Das lief seit April unentdeckt — 3 Monate lang. Kein Alert, kein Fehlerlog, kein “hey, ich kann nicht verbinden”. Der Wyoming-Endpoint war einfach nicht erreichbar, und keiner hat’s gemerkt.
🚨 Stiller Ausfall #2: Asterisk VoIP — seit Mai tot
Ein Asterisk-Container sollte VoIP-Telefonie ermöglichen. Die SIP-Registrierungen wurden rejected, null Calls verarbeitet. Das Log zeigte den letzten erfolgreichen Call im Mai.
Nicht migriert. Der Dienst wurde eingestellt — Configs im Backup, Container bleibt auf dem Todesstern. Manche Dinge sterben leise.
Post-Shutdown: Die Aufrufer-Karte
Nach allen drei Phasen stand die entscheidende Frage: Wer ruft eigentlich was auf? Du glaubst, du kennst deine Infrastruktur. Du irrst dich.
Deshalb habe ich eine Aufrufer-Karte erstellt — für jeden migrierten Service eine Liste aller Caller:
| Service | Caller | Verifiziert | Status |
|---|---|---|---|
| Keycloak | Gitea, Nextcloud, grommunio, Mealie, Jotty, Vaultwarden | Alle via curl aus Caller-Kontext | ✅ |
| n8n | Zabbix (Alertmanager), Zammad (Webhook), Telegram | Alertmanager 200, Zammad 200 | ✅ |
| step-ca | (kein aktiver ACME-Client) | Logs gecheckt, 0 Fehler | ✅ |
| Vaultwarden | Browser-Extension, Mobile Apps, bw CLI auf droide-r2 | bw sync, 200, Push funktioniert | ✅ |
Jeder Eintrag wurde vom Caller selbst aus verifiziert — nicht “der Service läuft”, sondern “der Gitea-Container kann sich bei Keycloak anmelden”:
# Verifikation: Aus Caller-Kontext, nicht aus Docker-Netzwerk
docker exec gitea curl -sI https://auth.rebellion-base.net/auth/realms/master/.well-known/openid-configuration
# → 200 OK
🚨 Zwei weitere stille Leichen aufgedeckt
Die Aufrufer-Karte deckte zwei unabhängige, alte Ausfälle auf:
1. Zammad IMAP-Archivierung — seit Mai tot
Zammad sollte archivierte Tickets (Jahre alt) per IMAP in ein Postfach schieben. Es gab ein Script dafür. Es gab keinen Cron-Job, der es ausführte. Das Script existierte seit Mai, wurde nie aktiviert.
Keine Migration, kein Rollback — es hatte nie funktioniert.
2. Wallet-Expiry-Checker — falsche Zabbix-IP
Ein Script, das ablaufende Zertifikate überwacht und eine Zabbix-Benachrichtigung auslöst, schickte an eine nicht-existierende IP:
# Config des Scripts — falsch seit einem Server-Umzug vor Monaten
ZABBIX_SERVER="192.168.66.10"
# Richtig wäre: 192.168.66.66 (executor, neuer Zabbix-Standort)
Auch das: Monate lang still ausgefallen, niemand hat’s gemerkt. Der Wallet-Expiry-Checker meldete einfach nie wieder was — und “keine Meldung” sieht ja nach “alles in Ordnung” aus.
Die finale Zerstörung
Nachdem alle drei Phasen abgeschlossen und alle Caller verifiziert waren:
- Proxmox Backup (
vzdump) des Todesstern-Containers — 116 GB, verifiziert lesbar - Einmaliges Tar-File aller einzigartigen Daten auf
droide-r2(zusätzliche Sicherung) pct destroy 110— der Todesstern explodiert
Ergebnis: 120 GB freigegeben auf dem Proxmox-Host.
Lessons Learned
Der Health-Monitor ist ein Doppelagent. Ein Cron-Job, der EXITED-Container löscht, macht aus einem simplen
docker stopeine irreversible Löschung. Migrations-Rollbacks müssen das einkalkulieren — oder den Cron vorher deaktivieren.DNS-Cutover ist Gold. Ein A-Record umbiegen ist der einzige Weg, eine Migration ohne Config-Friedhöfe durchzuführen. Voraussetzung: Alle Services nutzen DNS-Namen, keine hardcodierten IPs.
Hardcodierte IPs sind überall — und du findest sie erst beim Cutover. Alertmanager, Zammad-Webhooks, Backup-Scripts — überall lauern veraltete IPs. Systematisch suchen (grep über alle Configs, DB-Webhooks checken).
“Success” heißt nicht “funktioniert”. Drei n8n-Workflows zeigten “success” — aber riefen eine tote IP auf.
continueOnFail: truemacht aus Fehlern stille Gräber.Stille Ausfälle altern unbemerkt. Health-Monitor frisst deine DB → Plausible läuft wochenlang ohne Daten. Vaultwarden-Upgrade entfernt Port → Push tot seit Monaten. HA-Route durch VPN → Voice tot seit April. Kein Alarm, weil niemand je auf “es funktioniert nicht” getestet hat. Ein regelmäßiger End-to-End-Health-Check hätte alle gefunden.
Die Aufrufer-Karte ist dein wichtigstes Migrationswerkzeug. Vor dem Umzug: Alle Caller eines Services dokumentieren. Nach dem Umzug: Jeden Caller aus seinem eigenen Kontext verifizieren. Nicht “der Service läuft”, sondern “der Caller kann den Service erreichen”. Das deckt Routing-Probleme, DNS-Aliase und Firewall-Regeln auf, die du sonst übersiehst.
Ein Post-Mortem deckt immer mehr auf als die Migration selbst. Zwei unabhängige, alte Ausfälle (Zammad-Archiv, Wallet-Expiry) wurden nur gefunden, weil die Migration zur systematischen Überprüfung aller Abhängigkeiten zwang. Die Migration war der Vorwand — die echte Arbeit war das Aufräumen versteckter Schulden.
Checkliste: Docker-Host decommission
- Disk-Analyse vorher (wie voll ist der Host, was kann weg)
- Image/Volume Cleanup (Phase 0 — oft reicht das schon für Luft)
- Health-Monitor-Cron prüfen und ggf. deaktivieren
- Aufrufer-Karte: Für jeden Service alle Caller dokumentieren
- Images via
docker save/load(kein:latestbeim Cutover) - Daten via rsync mit gestopptem Source
- Pre-Test gegen neue IP (bevor DNS umgebogen wird)
- DNS-Cutover (TTL vorher senken auf 60s)
- Verifikation: Aus Caller-Kontext, nicht aus Docker-Netzwerk
- Reverse-Proxy: Alle externen URLs auf 200 prüfen
- Interne Chains testen: Alertmanager → n8n, n8n → Telegram, etc.
- Vom alten Host disconnecten: Können Caller den alten Host noch erreichen? (Sollten sie nicht.)
- Vor der Zerstörung: Backup + zusätzliches Tar-Archiv
- Nach der Zerstörung: Freigegebenen Speicher prüfen