- Shell 83.3%
- Dockerfile 16.7%
| ansible | ||
| caddy | ||
| docs | ||
| sqlite-tools | ||
| .gitignore | ||
| backup-vaultwarden.sh | ||
| README.md | ||
| vaultwarden.container | ||
| vaultwarden.env.example | ||
Vaultwarden — Bootstrap-Tresor des Homelabs
Bitwarden-kompatibler Passwort-Tresor. Läuft rootless unter einem eigenen
Service-User auf dem stabilen Teil des Homelabs (christians-m720q).
Dieser Tresor ist die Wurzel des Secret-Managements: aus ihm zieht Ansible später die Secrets für alle übrigen Dienste. Deshalb lebt er bewusst nicht auf dem Test-/Bau-Rechner.
Einordnung ins bestehende Setup
Alle Dienste laufen auf christians-m720q.
| Dienst | Modus | Daten | Unit-Typ |
|---|---|---|---|
| edge-proxy | rootful (root) | /opt/edge-proxy/ |
Quadlet .kube |
| vaultwarden | rootless, User vaultwarden |
/var/lib/vaultwarden/ |
Quadlet .container |
| jellyfin | rootless, User christian |
~/jellyfin/ |
handgeschriebene Unit (Altlast) |
| forgejo | rootless, User christian |
~/pod-forgejo/ |
handgeschriebene Unit (Altlast) |
Jellyfin und Forgejo werden bewusst nicht angefasst. Sie laufen.
Ablageorte der Unit-Dateien
Drei verschiedene Scopes — der häufigste Fehler ist, die Unit am falschen Ort zu suchen oder abzulegen.
| Dienst | Unit-Datei(en) | Steuerung |
|---|---|---|
| edge-proxy | /etc/containers/systemd/edge-proxy.kube/etc/containers/systemd/edge-proxy.yaml |
sudo systemctl start edge-proxy |
| vaultwarden | /var/lib/vaultwarden/.config/containers/systemd/vaultwarden.container |
sudo systemctl --machine=vaultwarden@ --user start vaultwarden |
| jellyfin | /etc/systemd/system/jellyfin.service |
sudo systemctl start jellyfin |
| forgejo | /etc/systemd/system/forgejo.service |
sudo systemctl start forgejo |
Merkregel für Quadlet:
| Scope | Verzeichnis |
|---|---|
| rootful | /etc/containers/systemd/ |
| rootless | <home-des-users>/.config/containers/systemd/ |
Logs
journalctl -u edge-proxy.service -f
journalctl -u jellyfin.service -f
sudo journalctl --machine=vaultwarden@ --user -u vaultwarden -f
Kein systemctl enable bei Quadlet
Quadlet-Units sind generated — systemctl enable schlägt fehl. Der
Boot-Start kommt allein aus der [Install]-Sektion und wird beim
daemon-reload verdrahtet. Prüfen mit systemctl is-enabled <unit>:
generated oder enabled bedeuten beide, dass die Unit beim Boot startet.
Nach jeder Änderung an einer .kube/.container/.yaml:
daemon-reload im passenden Scope, dann restart.
Voraussetzungen
- Podman ≥ 4.4 (für Quadlet):
podman --version - DNS-Eintrag
vault.duske.it→ öffentliche IP des VPS
Grundsatz: auf dem Host wird nichts installiert. Werkzeuge, die nur für Wartungsaufgaben gebraucht werden (z.B.
sqlite3fürs Backup), laufen in Wegwerf-Containern. Deren Definition liegt im Repo (sqlite-tools/Containerfile) und ist damit beim Neuaufbau automatisch vorhanden — im Gegensatz zu einem undokumentiertenapt install, das man nach einem halben Jahr garantiert vergessen hat.
Installation
1. Subid-Allokation einmalig konfigurieren
Einmalig pro Host, gilt danach für alle weiteren Dienste. Damit vergibt
useradd die Ranges selbst und garantiert Überschneidungsfreiheit — sicherer
als manuelle Vergabe, bei der Überlappungen die Isolation still aufheben.
Bestand ansehen und sichern:
grep -E '^SUB_(UID|GID)' /etc/login.defs
cat /etc/subuid /etc/subgid
sudo cp /etc/login.defs /etc/login.defs.bak
Debian hat diese Werte bereits gesetzt (Default SUB_UID_MIN 100000) — daher
stammt der bestehende Eintrag christian:100000:65536. Es wird also nur das
Fenster verschoben, nicht erstmalig aktiviert.
In /etc/login.defs anpassen:
SUB_UID_MIN 524288
SUB_UID_MAX 1878982655
SUB_UID_COUNT 65536
SUB_GID_MIN 524288
SUB_GID_MAX 1878982655
SUB_GID_COUNT 65536
Start bei 524288, weil systemd diesen Bereich ausdrücklich für Container-UID-Ranges reserviert.
Bestehende Einträge bleiben unberührt.
christiansRange liegt danach unterhalb des Fensters und wird für Neuvergaben nie wieder in Betracht gezogen — Überlappung ist strukturell ausgeschlossen.
christianbewusst NICHT migrieren. Eine geänderte Range würdepodman system migrateund ein Remapping des gesamten rootless Storage erfordern — und damit Jellyfin und Forgejo anfassen.
Gefahrlos verifizieren mit einem Wegwerf-User — ohne --system, sonst
wird nie eine Range vergeben und der Test sagt nichts aus:
sudo useradd --create-home --home-dir /tmp/subidtest \
--shell /usr/sbin/nologin subidtest
getsubids subidtest # sollte etwas ab 524288 zeigen
sudo userdel -r subidtest
Status auf
christians-m720q: erledigt und verifiziert. Fenster steht, Auto-Vergabe greift.christian:100000:65536unverändert. Der Ansible-Task dazu liegt inansible/tasks-host-subid.yml.
2. Service-User anlegen — bewusst OHNE --system
sudo useradd --create-home --home-dir /var/lib/vaultwarden \
--shell /usr/sbin/nologin vaultwarden
Warum kein
--system? Auf diesem Host verifiziert:useradd --systemvergibt grundsätzlich keine Subid-Range. shadow entscheidet das am Flag, nicht an der resultierenden UID.Damit sind drei Anforderungen nicht gleichzeitig erfüllbar:
Variante UID auto Subid auto UID-Bereich useradd --systemja nein 100–999 useradd --uid Nnein ja frei wählbar useradd(regulär)ja ja ab 1000 Gewählt ist die dritte Zeile: beide Automatiken zahlen direkt auf das Ansible-Ziel ein, während die UID im Systembereich reine Konvention ohne funktionale Wirkung ist. Ein UID-1001-Account kann exakt dasselbe wie ein UID-997-Account —
--systemändert weder Rechte noch Capabilities noch das Verhalten von systemd.
Funktional ist das trotzdem ein Dienstaccount: kein Passwort (useradd
schreibt ! ins Passwortfeld), kein Login (nologin). Prüfen:
sudo passwd -S vaultwarden # muss "L" für locked zeigen
3. Subid-Vergabe verifizieren
Mit Variante A vergibt shadow die Range automatisch aus dem in Schritt 1 gesetzten Fenster:
getsubids vaultwarden
grep vaultwarden /etc/subuid /etc/subgid
Erwartet: vaultwarden:524288:65536 in beiden Dateien — 524288 ist der
erste freie Block. Fehlt der Eintrag, wurde versehentlich mit --system
angelegt.
Range und die vergebene Account-UID (id -u vaultwarden) notieren und in
die Ansible-Variablen übernehmen. Beide sind hostabhängig; für einen
reproduzierbaren Rebuild müssen sie gepinnt werden.
4. Lingering aktivieren
Sorgt dafür, dass /run/user/<uid> schon beim Boot existiert und die
User-Units ohne Login starten. Genau der Punkt, an dem das Forgejo-Setup
seinerzeit gescheitert ist.
sudo loginctl enable-linger vaultwarden
sudo loginctl show-user vaultwarden | grep Linger # muss Linger=yes zeigen
5. Repo ablegen
Die Unit-Datei muss erst einmal auf den Host. Sinnvollerweise als Git-Repo, das später nach Forgejo gepusht wird:
mkdir -p ~/git/homelab-vaultwarden
cd ~/git/homelab-vaultwarden
# Inhalt dieses Repos hier ablegen, dann:
git init
git add -A
git commit -m "Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku"
Vor dem ersten Commit prüfen, dass die .gitignore greift — sie muss da
sein, bevor irgendwo ein Secret entsteht:
git status --ignored
git ls-files | grep -iE 'env|key|pem|secret' # darf nur *.env.example zeigen
6. Verzeichnisse und Unit ablegen
Aus dem Repo-Verzeichnis heraus:
sudo -u vaultwarden mkdir -p /var/lib/vaultwarden/data
sudo -u vaultwarden mkdir -p /var/lib/vaultwarden/.config/containers/systemd
sudo install -o vaultwarden -g vaultwarden -m 644 \
vaultwarden.container /var/lib/vaultwarden/.config/containers/systemd/
7. ADMIN_TOKEN erzeugen — manueller Bootstrap-Schritt
Das ist das Henne-Ei-Secret. Es kann per Definition nicht aus dem Tresor kommen, den es selbst schützt. Es lebt deshalb ausserhalb der Automatisierung: Passwortmanager auf dem Handy, Papier im Safe, oder ein age-verschlüsselter Blob mit Key auf Hardware-Token. Niemals ins Git. Auch nicht verschlüsselt "nur mal kurz".
podman run --rm -it docker.io/vaultwarden/server /vaultwarden hash
Ausgabe in die Env-Datei übernehmen (Vorlage: vaultwarden.env.example):
sudo -u vaultwarden vim /var/lib/vaultwarden/vaultwarden.env
sudo chmod 600 /var/lib/vaultwarden/vaultwarden.env
8. Starten
sudo systemctl --machine=vaultwarden@ --user daemon-reload
sudo systemctl --machine=vaultwarden@ --user start vaultwarden
sudo systemctl --machine=vaultwarden@ --user status vaultwarden
Falls
--machinenicht funktioniert: Es brauchtsystemd-containerauf dem Host. Ist das Paket nicht da, geht es auch mit explizit gesetzter Umgebung —nologin-Accounts haben nie eine Session, in derXDG_RUNTIME_DIRautomatisch gesetzt würde:VWUID=$(id -u vaultwarden) sudo -u vaultwarden XDG_RUNTIME_DIR=/run/user/$VWUID \ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$VWUID/bus \ systemctl --user daemon-reloadVoraussetzung ist in beiden Fällen Schritt 4 (Linger) — ohne ihn existiert
/run/user/<uid>überhaupt nicht.
Logs:
sudo journalctl --machine=vaultwarden@ --user -u vaultwarden -f
9. Caddy
Inhalt von caddy/vault.duske.it.caddy in /opt/edge-proxy/Caddyfile
übernehmen. Upstream-IP anpassen — siehe Kommentar in der Datei, 127.0.0.1
funktioniert dort nicht.
# Config-Syntax prüfen, bevor der laufende Proxy angefasst wird
sudo podman exec edge-proxy-caddy caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart edge-proxy.service
sudo podman logs edge-proxy-caddy | tail -30
Ein ruhiger Caddy-Log ist der gute Fall. Zeilen mit obtaining certificate
tauchen nur beim ersten Mal auf; danach werden die Certs aus dem persistenten
caddy-data/-Volume geladen.
10. Ersten Account anlegen
- In der
.containertemporärSIGNUPS_ALLOWED=truesetzen, neu starten - Auf
https://vault.duske.itregistrieren - Zurück auf
false,daemon-reload+restart - Weitere Nutzer danach über
https://vault.duske.it/admineinladen
Backup
backup-vaultwarden.sh sichert DB (transaktionssicher), rsa_key.pem,
Attachments, Sends und die Env-Datei. Das sqlite3-Werkzeug kommt aus einem
Container, nicht vom Host.
Vorab prüfen, ob das Vaultwarden-Image sqlite3 schon mitbringt — dann spart
man sich das Zusatz-Image. Erst möglich, wenn der Container läuft (nach
Schritt 8 der Installation):
sudo -u vaultwarden XDG_RUNTIME_DIR=/run/user/$(id -u vaultwarden) \
podman exec vaultwarden sqlite3 --version
Zwei Stolperfallen auf einmal:
sudo podman ...ohne-u vaultwardenläuft im root-Kontext und findet den Container nicht (no such container). Rootless-Container sind pro User getrennt.Aber auch
sudo -u vaultwarden podman ...allein reicht nicht: Es fehltXDG_RUNTIME_DIR, und ohne die Variable findet rootless Podman seinen Socket nicht.nologin-Accounts haben nie eine Session, in der sie automatisch gesetzt würde — die Variable muss explizit mitgegeben werden. Voraussetzung ist ausserdemenable-linger, sonst existiert/run/user/<uid>gar nicht.
Ohne
rsa_key.pemist die Datenbank wertlos. Sie lässt sich dann nicht mehr entschlüsseln — auch nicht mit dem richtigen Masterpasswort.
Das Backup enthält Secrets und muss selbst verschlüsselt abgelegt werden.
Was die Handy-App abdeckt — und was nicht
Ein eingeloggter Bitwarden-Client hält einen lokalen Cache des Vaults. Das ist ein brauchbarer Notfall-Zugang, aber kein Server-Backup:
| Client-Cache | Backup | |
|---|---|---|
| eigene Einträge, offline lesbar | ✅ | ✅ |
rsa_key.pem |
❌ | ✅ |
| Accounts anderer Nutzer | ❌ | ✅ |
| Attachments / Sends | teilweise | ✅ |
| Server wiederherstellbar | ❌ | ✅ |
Stirbt der m720q, kommt man mit dem Handy an die eigenen Passwörter — der Dienst lässt sich daraus aber nicht rekonstruieren. Für die Bootstrap-Phase ist das ein akzeptabler Zustand, solange er bewusst ist.
Portables Zwischenartefakt ohne grossen Aufwand:
bw export --format encrypted_json --output vault-export.json
Offene Punkte
- LAN-IP des m720q für den Caddy-Upstream ermitteln und eintragen
Läuft der edge-proxy auf dem m720q?→ ja, rootful auf demselben Host. Container:edge-proxy-wireguard+edge-proxy-caddy, Tunnel zu einem VPS, der öffentlich 80/443 annimmt.- Backup per systemd-Timer automatisieren
- Restore einmal auf dem Test-Rechner durchspielen — ein ungetestetes Backup ist kein Backup
- Ansible-Rolle aus dieser Anleitung ableiten (siehe
docs/learnings.md) → erledigt, Fenster ab 524288. Ansible-Task in/etc/login.defsauf Auto-Allokation umstellenansible/tasks-host-subid.yml- Vergebene Subid-Range und Account-UID auslesen und in die Ansible-Variablen pinnen — mit Variante A sind beide hostabhängig
- Secrets-Handling überdenken:
podman secretbzw.LoadCredential=stattEnvironmentFile=— sauberer, weil das Secret dann nicht als Umgebungsvariable im Prozess sichtbar ist podman-auto-update.timerpro Service-User aktivieren, sonst bleibtAutoUpdate=registrywirkungslos