NixOS + Rootless Podman: Mein Sicheres Homelab
- nixos
- podman
- homelab
- security
Ausgangspunkt
Aktuell betreibe ich bei mir zu Hause einen Proxmox Server mit einer Debian VM. Früher habe ich so etwas immer ganz klassisch im Terminal über SSH gemacht und alles per Hand eingetippt. Im Rahmen meiner Arbeit bei der dev-threads GmbH durfte ich mich vor zwei Jahren näher mit Ansible beschäftigen und habe seitdem Stück für Stück mein Homelab umgebaut.
Durch Ansible wird die ganze Konfiguration mit Textdateien beschrieben, wodurch es mir möglich war git als Versionskontrolle zu verwenden. Das ist besonders hilfreich, da man mit der Zeit vieles vergisst und das Repository als Dokumentation dient. Und wenn mir mal eine VM verloren geht, kann ich alles wiederherstellen, selbst wenn etwas mit den Backups der VM nicht stimmt.
Die verschiedenen Dienste, die auf der VM laufen, sind Container, die mit Docker ausgeführt werden, das schafft Separation und verbessert die Wartbarkeit enorm. Beispielsweise kann ich die verwendeten Versionen ganz genau bestimmen und auch mal ein Rollback durchführen, ohne andere Dienste zu beeinträchtigen. Dadurch, dass alles in Textdateien gespeichert wird, passt es außerdem zu Ansible und einem git Repository.
Debian als Host-Betriebssystem hat mir bisher immer gute Dienste geleistet seit meinen ersten Gehversuchen in den 2000ern. Es zeichnet sich durch gut abgehangene Pakete und hohe Stabilität aus, eigentlich perfekt!
Allerdings gibt es auch hin und wieder Probleme, wenn z.B. mal wieder der Nvidia-Treiber nach einem Kernel-Upgrade nicht mehr funktioniert, weil die Kernel-Header gefehlt haben. Das ist zwar sicherlich eine kaputte Konfiguration meinerseits, allerdings ist diese augenscheinlich nicht Teil meines Ansible-Repositories und ich habe somit irgendwo einen Konfigurationsdrift.
NixOS verspricht durch seine deklarative Konfiguration all diese Probleme zu lösen. Der gesamte Zustand entspricht genau dem, was wir vorgeben. Leider gibt es kein NixOS-Paket, das das bestehende Docker-Compose-Setup unterstützt. Da ich aber den Aufwand so gering wie möglich halten will und mich nicht vollständig der NixOS-Welt verschreiben will, muss mein Vorhaben meine bisherigen Compose-Dateien unterstützen.
Der Plan
NixOS als Betriebssystem, Podman statt Docker und ein eigener Serviceuser pro Stack.
NixOS
NixOS gibt mir eine deklarative Systembeschreibung: Eine configuration.nix definiert alles — Pakete, Benutzer, Firewall, Services. Was nicht drinsteht, existiert nicht. Wenn ich die Maschine formatiere, Daten wiederherstelle und die Config anwende, erreiche ich wieder exakt denselben Zustand.
Podman statt Docker
Der Docker Daemon wird in der Standardkonfiguration mit root-Rechten gestartet. Gelingt es einer kompromittierten Anwendung aus einem Container auszubrechen, braucht es keine weiteren Exploits mehr, der Zugriff aufs System ist ohne Umwege gegeben. Während es bei Docker mittlerweile auch einen rootless Modus gibt, ist dieser bei Podman ein Kernaspekt, um den das Tool entwickelt wurde.
Isolierte Services
Ich könnte es mir einfach machen und einen Systembenutzer für alle Podman-Container verwenden, allerdings ist es dank NixOS nicht viel aufwendiger, für jeden Service einen eigenen Benutzer zu erstellen. So erreichen wir nicht nur eine Isolation von root, sondern auch untereinander.
Jeder Service bekommt seinen eigenen Benutzer mit fester UID (ab 60000). Caddy hat 60002, Jellyfin 60003 und so weiter. Die UIDs sind fest definiert, nicht zufällig vergeben, damit Dienste zwischen Servern verschoben werden können.
Die Umsetzung
Beispiel - Homepage
Homepage ist ein Dashboard, das ich in meinem Homelab verwende um schnell zu den jeweiligen Services zu kommen. Es eignet sich sehr gut als Beispiel, weil es im Grunde nur eine statische Website ist und einfach konfiguriert werden kann.
Compose
Diese einfache Datei beschreibt den Homepage Service.
# /var/docker/homepage/docker-compose.yaml
---
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
restart: unless-stopped
ports:
- "3000:3000"
Rootless Podman
Podman startet in der Standardeinstellung unprivilegiert, der rootless Modus muss daher nicht erst aktiviert werden.
virtualisation.podman.enable = true;
Da ich möchte, dass meine existierenden docker-compose.yaml Dateien unterstützt werden, muss ich noch dockerCompat und DNS aktivieren. dockerCompat ermöglicht die Verwendung von Docker Compose und DNS die Auflösung von Container-Hostnamen, die ich an vielen Stellen verwende.
virtualisation.podman = {
enable = true;
dockerCompat = true;
defaultNetwork.settings.dns_enabled = true;
};
Service User
Für die größte Isolation erstelle ich für jeden Service einen eigenen Benutzer.
users.users."svc-homepage" = {
isSystemUser = true;
uid = 60000;
group = "svc-homepage";
home = "/var/docker/homepage";
createHome = false;
subUidRanges = [{ startUid = 165536; count = 65536; }];
subGidRanges = [{ startGid = 165536; count = 65536; }];
linger = true;
};
users.groups."svc-homepage" = { gid = 60000; };
Der Benutzer svc-homepage darf nur auf /var/docker/homepage zugreifen, wo alle relevanten Konfigurationsdateien platziert werden (in diesem Fall nur die docker-compose.yaml). linger = true hält die systemd-Benutzersitzung am Leben, auch wenn niemand eingeloggt ist — so startet der Service automatisch beim Systemstart.
Service Installation
Für jeden compose stack braucht es einen entsprechenden systemd service. Dieser startet und stoppt die Container. Hier ist die ganze Definition, aber gehen wir das mal im Detail durch.
systemd.services."podman-compose-homepage" = {
description = "Podman Compose for homepage";
after = [ "network-online.target" "user@60000.service" ];
wants = [ "network-online.target" ];
requires = [ "user@60000.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.docker-compose "/run/wrappers" ];
unitConfig = {
ConditionPathExists = "/var/docker/homepage";
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "svc-homepage";
Group = "svc-homepage";
WorkingDirectory = "/var/docker/homepage";
Environment = "XDG_RUNTIME_DIR=/run/user/60000";
ExecStart = "${pkgs.podman}/bin/podman compose up -d --remove-orphans";
ExecStop = "${pkgs.podman}/bin/podman compose down";
};
};
path = [ pkgs.docker-compose "/run/wrappers" ];
Macht docker-compose für den Service-Benutzer verfügbar.
Der systemd service verwendet zwar podman compose, allerdings handelt es sich hierbei lediglich um einen Wrapper für docker compose.
User = "svc-homepage";
Group = "svc-homepage";
Der compose stack muss mit dem Benutzer svc-homepage gestartet werden.
Der compose Befehl erwartet die Dateien im aktuellen Verzeichnis, daher wechseln wir zum home von svc-homepage wo alles liegt.
WorkingDirectory = "/var/docker/homepage";
Environment = "XDG_RUNTIME_DIR=/run/user/60000";
Sofern definiert, legt Podman temporäre Dateien unter ${XDG_RUNTIME_DIR}/containers ab.
Damit stellen wir sicher, dass temporäre Dateien ordentlich jedem Benutzer zugeordnet sind.
ExecStart = "${pkgs.podman}/bin/podman compose up -d --remove-orphans";
ExecStop = "${pkgs.podman}/bin/podman compose down";
Zu guter Letzt muss ein Service gestartet und gestoppt werden können. Unter NixOS ist Stoppen mindestens genauso wichtig wie das Starten, denn ansonsten würde nach einem Konfigurationsupdate von NixOS der Service unter Umständen noch laufen, das wäre zwar nach einem Neustart geklärt, sollte aber dennoch vermieden werden.
Netzwerk
Damit wir von außen auf das Dashboard zugreifen können, müssen wir Port 3000 in der Firewall öffnen.
⚠️ Soll der Service aus dem Internet erreichbar sein, bitte mindestens mit HTTPS absichern, hierfür bietet sich ein reverse proxy (z.B. nginx oder caddy) an. Die Freigabe ohne eine Sicherheitsvorkehrung ist nur für das Beispiel gedacht.
networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [ 3000 ];
Nachteile
Vorsicht beim Netzwerk
Ohne Root-Rechte kann Podman kein eigenes Bridge-Netzwerk zwischen Compose-Stacks aufspannen. Die Services kommunizieren stattdessen über das Host-Netzwerk. Diese Einschränkung gibt es so nur in meinem Setup, weil ich für jeden Service eine eigene Podman-Instanz habe.
Schlussendlich bedeutet das, dass die Firewall hier eine größere Rolle spielt als bei klassischem Docker mit einem Benutzer (root), wo die Container über ein isoliertes Netzwerk verbunden sind.
Allerdings ist die Firewallkonfiguration bei NixOS überschaubar. Dennoch ist es wichtig darauf hinzuweisen.
Logs lesen ist umständlicher
Bei Docker reicht ein einfaches docker logs <container> mit Root-Rechten.
Mit rootless Podman und separaten Service-Usern muss man sich erst mit dem entsprechenden User anmelden.
Das ist relativ lästig und umständlich.
In meiner Umgebung schaffe ich mir Abhilfe mit Dozzle — ein Log-Viewer, der rootless Podman nativ unterstützt und alle Container-Logs über eine Weboberfläche zugänglich macht. Dazu muss Dozzle im Agent Modus konfiguriert sein und jeder compose stack benötigt einen dozzle agent der die logs exportiert.
Fazit
Docker mit Root-Daemon ist bequem und wie wir wissen, ist Sicherheit nie bequem. Die strikte Trennung von Services bei Podman und der fehlende Root Zugang fühlen sich richtig an und mit NixOS war die Umsetzung überschaubar.
Noch habe ich meinen Produktivserver nicht umgezogen, aber ich melde mich hier nochmal, wenn ich Langzeiterfahrungen gesammelt habe.