Der 35-Watt-Mitbewohner (Teil 2): Die Cloud im Schrank

In Teil 1 ging es um die Hardware: Wie der Lenovo M920q meine überforderte Synology als Compute-Knoten abgelöst hat. Die Hardware steht, Proxmox schnurrt. Jetzt die eigentliche Frage: Wie kommen die Dienste sicher ins Internet, ohne den Router zum Schweizer Käse zu machen?

Infrastructure as Code: Alles im Git

Container manuell zusammenklicken ist nichts für mich. Wenn ich etwas zerschieße, will ich es per Knopfdruck wiederaufbauen können. Mein Setup dafür: Ein Git-Repository mit einem YAML-File pro Service.

Anstatt alles in eine riesige docker-compose.yml zu werfen, habe ich meine Services modular als Portainer-Stacks aufgebaut:

  • gitea.yml Mein privates Code-Zuhause inklusive Gitea Runner für CI/CD.

  • jellyfin.yml Media-Streaming für den Feierabend. Die Filme liegen auf der Synology, gestreamt wird vom M920q. Damit das funktioniert, muss das Media-Volume der NAS per NFS oder SMB auf dem Docker-Host gemountet sein. In meinem Fall ein einfacher Eintrag in /etc/fstab:

    //192.168.1.x/media /mnt/nas-media cifs credentials=/etc/nas-credentials,uid=1000,gid=1000 0 0

    Jellyfin greift dann über ein Bind-Mount auf /mnt/nas-media zu.

  • vaultwarden.yml Passwort-Manager für alle Geräte. Dazu unten mehr.

  • web.yml Traefik als Reverse Proxy für meine Webseiten (Blog, Portfolio, howtolosemoneyfast etc.).

  • tunnel.yml Der Cloudflare Tunnel, der alles zusammenhält.

Neuer Service? YAML-File anlegen, in Portainer als Stack deployen, fertig. Alles kaputt? git pull, Stacks neu deployen, alles wieder online.

Cloudflare Tunnels: Kein Port offen, trotzdem erreichbar

Früher hieß es: Port 80 und 443 im Router öffnen, DynDNS einrichten und hoffen, dass niemand an der Haustür rüttelt. Heute geht das ohne offene Ports.

Der Cloudflare Tunnel (cloudflared) baut eine verschlüsselte Verbindung von innen nach außen zu Cloudflare auf. Kein eingehender Port muss geöffnet werden. Cloudflare übernimmt TLS-Terminierung, DDoS-Schutz und DNS.

Zwei Dinge, die das für mein Setup so wertvoll machen:

  1. Keine Portfreigaben. Mein Router bleibt komplett dicht. Port-Scanner laufen ins Leere.
  2. Zero Trust per Cloudflare Access. Bevor jemand die Login-Maske von Jellyfin oder Gitea sieht, muss eine E-Mail-Verifizierung bestanden werden. Nur freigegebene E-Mail-Adressen kommen durch.
  3. Eine Subdomain pro Service. Im Cloudflare Dashboard legt man für jeden Service eine eigene Subdomain an und zeigt sie auf den internen Port. Also z.B. jellyfin.example.comhttp://jellyfin:8096, git.example.comhttp://gitea:3000. Jede Subdomain bekommt eigene Access-Regeln. So bleibt alles sauber getrennt und man kann pro Service steuern, wer Zugriff hat.

Die Architektur

So sieht der Weg vom Browser bis zum Service aus:

Der cloudflared-Container verbindet sich beim Start automatisch mit Cloudflare. Alle Services hängen direkt am selben Docker-Network wie cloudflared und sind darüber erreichbar. Nur für die statischen Webseiten (Blog, Portfolio) steht Traefik als Reverse Proxy davor, weil dort mehrere Domains auf unterschiedliche Nginx-Container geroutet werden. Die Synology bleibt reiner Storage-Knoten: Filme und Backups.

Wichtig: Lokalen Traffic sollte man nicht durch den Cloudflare Tunnel schicken. Wenn der TV im Wohnzimmer einen Film streamt, ergibt es keinen Sinn, die Daten erst zu Cloudflare und dann zurück ins eigene Netzwerk zu jagen. Das kostet Latenz und Bandbreite. Stattdessen gehen TV und Notebook direkt per IP auf Jellyfin (Port 8096). Der Tunnel ist nur für den Zugriff von außerhalb.

Vaultwarden statt KeePass + Google Drive

Vorher lief das bei mir so: KeePass-Datenbank auf Google Drive, auf allen Geräten die gleiche .kdbx. Funktioniert, bis man am Laptop ein Passwort ändert und am Handy noch die alte Version offen hat. Speichern, Sync-Konflikt, zwei Dateien. Dann darf man von Hand zusammenführen und hoffen, dass nichts verloren geht. Das ist mir oft genug passiert, dass ich irgendwann keinen Bock mehr hatte.

Vaultwarden ist eine Rust-Reimplementierung der Bitwarden-Server-API. Der Clou: Man nutzt einfach die offiziellen Bitwarden-Clients und Browser-Extensions, trägt als Server-URL die eigene Domain ein und loggt sich ein. Die Clients merken keinen Unterschied zum gehosteten Bitwarden, aber die Daten liegen bei mir.

Der Sync funktioniert über alle Geräte, Autofill im Browser und am Handy läuft, und Passwörter mit der Familie teilen geht über Organisationen. Der Container braucht unter 100 MB RAM.

Für mich war das der Service, der den ganzen Homelab-Aufwand am schnellsten gerechtfertigt hat. Kein Google Drive mehr in der Kette, keine kaputten .kdbx-Dateien.

Deployment per Shared Volume

Ein Detail, auf das ich ziemlich stolz bin: Wie die Webseiten vom Git-Push bis zum Live-Gang kommen, ohne dass irgendwo Dateien kopiert oder rsync laufen muss.

Der Gitea Runner hat Zugriff auf bestimmte Docker Volumes, die in seiner Config als valid_volumes hinterlegt sind, z.B. blog-astro-content-prd oder howtolosemoney-content-prd. Wenn eine CI-Pipeline läuft, baut der Runner die statische Seite und schreibt das Ergebnis direkt in das passende Volume. Die Nginx-Container in web.yml mounten genau diese Volumes als Read-Only:

# web.yml (Auszug)
blog-astro-web-prd:
  image: nginx:alpine
  volumes:
    - blog-astro-content-prd:/usr/share/nginx/html:ro
# gitea.yml (Runner-Config, Auszug)
container:
  valid_volumes:
    - "blog-astro-content-prd"
    - "howtolosemoney-content-prd"
    - "portfoliomanager-content-prd"

Git push, Runner baut, Ergebnis landet im Volume, Nginx liefert aus. Kein SCP, kein Webhook, kein zweiter Deploy-Schritt. Die Nginx-Container merken nicht mal, dass sich was geändert hat, weil sie einfach das Filesystem lesen.

FritzBox, IP-Wechsel und die Bypass-Rule

Cloudflare Access schützt meine Services mit E-Mail-Verifizierung. Aus dem eigenen Netzwerk will ich das aber nicht jedes Mal machen. Dafür gibt es eine Zero Trust Bypass-Policy, die meine öffentliche IP durchlässt. Problem: Die FritzBox bekommt bei jeder Zwangstrennung eine neue IP.

Die Lösung ist ein Webhook. In der FritzBox ist unter “Internet > Freigaben > DynDNS” eine Update-URL auf meinen nas-manager-hooks Container konfiguriert. Wenn sich die IP ändert, ruft die FritzBox den Webhook auf und der triggert ein Python-Script, das:

  1. Die aktuelle IPv4 und IPv6 über externe Dienste abfragt (ipify, ident.me)
  2. Aus der IPv6-Adresse das /64-Netzwerk berechnet
  3. Die Cloudflare Zero Trust Bypass-Policy per API mit den neuen IPs aktualisiert

Warum extern abfragen? Die FritzBox gibt beim DynDNS-Callback zwar die IPv4 mit, aber keine IPv6-Adresse. Also holt sich das Script die IPv6 selbst, über api6.ipify.org und als Fallback v6.ident.me. Wenn die Abfrage fehlschlägt, wird mit Exponential Backoff bis zu fünfmal wiederholt.

Das Ergebnis: IP wechselt, FritzBox meldet sich, Script läuft, Bypass-Policy ist aktuell. Kein Cron-Job, kein Polling.

Performance im Alltag CPU-Auslastung dabei: selten über 30%. Dank der Glasfaseranbindung merken meine Freunde keinen Unterschied zu einem gehosteten Dienst.

Ein paar Zahlen aus dem laufenden Betrieb:

MetrikWert
RAM-Verbrauch (alle Container)~12 GB
Idle-Stromverbrauch~10 W
Stromverbrauch unter Last~35 W
Aktive Docker-Container9

Was kommt in Teil 3?

Mysteriöse Netzwerkausfälle, immer genau dann, wenn abends ein Film laufen soll? In Teil 3 tauche ich in die Netzwerk-Logs ein und finde heraus, ob der M920q wirklich ein guter Mitbewohner ist oder ob er heimlich das WLAN lahmlegt.