Rootless Vaultwarden unter eigenem Service-User: Quadlet-Unit, Caddy-Anbindung, Backup und dokumentierte Begründungen. Bootstrap-Tresor des Homelabs.
  • Shell 83.3%
  • Dockerfile 16.7%
Find a file
2026-08-17 20:06:13 +02:00
ansible Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
caddy Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
docs Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
sqlite-tools Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
.gitignore gitignore ergaenzt 2026-08-17 20:06:13 +02:00
backup-vaultwarden.sh Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
README.md Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
vaultwarden.container Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00
vaultwarden.env.example Vaultwarden: Quadlet, Caddy-Snippet, Backup, Doku 2026-08-17 19:53:23 +02:00

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. sqlite3 fü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 undokumentierten apt 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. christians Range liegt danach unterhalb des Fensters und wird für Neuvergaben nie wieder in Betracht gezogen — Überlappung ist strukturell ausgeschlossen.

christian bewusst NICHT migrieren. Eine geänderte Range würde podman system migrate und 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:65536 unverändert. Der Ansible-Task dazu liegt in ansible/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 --system vergibt 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 --system ja nein 100–999
useradd --uid N nein 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 --machine nicht funktioniert: Es braucht systemd-container auf dem Host. Ist das Paket nicht da, geht es auch mit explizit gesetzter Umgebung — nologin-Accounts haben nie eine Session, in der XDG_RUNTIME_DIR automatisch 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-reload

Voraussetzung 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

  1. In der .container temporär SIGNUPS_ALLOWED=true setzen, neu starten
  2. Auf https://vault.duske.it registrieren
  3. Zurück auf false, daemon-reload + restart
  4. Weitere Nutzer danach über https://vault.duske.it/admin einladen

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 vaultwarden lä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 fehlt XDG_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 ausserdem enable-linger, sonst existiert /run/user/<uid> gar nicht.

Ohne rsa_key.pem ist 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)
  • /etc/login.defs auf Auto-Allokation umstellen → erledigt, Fenster ab 524288. Ansible-Task in ansible/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 secret bzw. LoadCredential= statt EnvironmentFile= — sauberer, weil das Secret dann nicht als Umgebungsvariable im Prozess sichtbar ist
  • podman-auto-update.timer pro Service-User aktivieren, sonst bleibt AutoUpdate=registry wirkungslos