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:

PhaseZielHostIPContainer
AAppsecho-basis192.168.66.67Firefly III, n8n, Mealie, Jotty (8 Container)
BInfrastrukturexecutor192.168.66.66Keycloak, Vaultwarden, step-ca (5 Container)
CKI-Stackdroide-r2192.168.66.62Ollama, 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:

  1. Du stoppst einen Container für die Migration → Container wechselt zu EXITED
  2. 5 Minuten später: docker rm -f löscht ihn
  3. Dein Rollback-Plan “einfach docker start ausfü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:

ServiceCallerVerifiziertStatus
KeycloakGitea, Nextcloud, grommunio, Mealie, Jotty, VaultwardenAlle via curl aus Caller-Kontext
n8nZabbix (Alertmanager), Zammad (Webhook), TelegramAlertmanager 200, Zammad 200
step-ca(kein aktiver ACME-Client)Logs gecheckt, 0 Fehler
VaultwardenBrowser-Extension, Mobile Apps, bw CLI auf droide-r2bw 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:

  1. Proxmox Backup (vzdump) des Todesstern-Containers — 116 GB, verifiziert lesbar
  2. Einmaliges Tar-File aller einzigartigen Daten auf droide-r2 (zusätzliche Sicherung)
  3. pct destroy 110 — der Todesstern explodiert

Ergebnis: 120 GB freigegeben auf dem Proxmox-Host.

Lessons Learned

  1. Der Health-Monitor ist ein Doppelagent. Ein Cron-Job, der EXITED-Container löscht, macht aus einem simplen docker stop eine irreversible Löschung. Migrations-Rollbacks müssen das einkalkulieren — oder den Cron vorher deaktivieren.

  2. 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.

  3. 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).

  4. “Success” heißt nicht “funktioniert”. Drei n8n-Workflows zeigten “success” — aber riefen eine tote IP auf. continueOnFail: true macht aus Fehlern stille Gräber.

  5. 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.

  6. 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.

  7. 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 :latest beim 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