Skip to content

16 — Vaultwarden (Password Manager)

A self-hosted, Bitwarden-compatible password manager replacing iCloud Passwords (formerly iCloud Keychain) as the source of truth for logins, TOTP codes, and — going forward — passkeys. Single lightweight Rust container, SQLite storage, Tailscale-only, fronted by a Let's Encrypt cert because every Bitwarden client requires HTTPS for a self-hosted server.

Now behind Caddy (May 2026) — replaces the tailscale serve --https=8443 setup this page describes. The HTTPS front-end is now the Caddy reverse proxy: https://vault.z2mini.gabrielgabrie.com (no port; auto-renewing Let's Encrypt cert via Cloudflare DNS-01). The container binds 127.0.0.1:8080 only (the 100.67.235.68:8080 binding added during the cutover was dropped). And critically — DOMAIN in .env is now https://vault.z2mini.gabrielgabrie.com: that's the URL clients connect to AND the WebAuthn / passkey relying-party ID, so it's load-bearing — don't change it once passkeys exist (none do yet; the twofactor table was empty as of 2026-05-12). So: - Every Bitwarden client (iOS app, desktop app, browser extension, bw CLI) → Self-hosted → Server URL = https://vault.z2mini.gabrielgabrie.com. Changing DOMAIN invalidated existing sessions, so each client re-logs in once. - On the box itself: http://127.0.0.1:8080. The old https://z2mini.elk-kanyu.ts.net:8443 endpoint is gone. - Recovery: the HTTPS front-end is the Caddy stack under /data/docker/caddy/ (rsync-able) — not tailscale serve config in tailscaled state.

Install Phase 2 below ("HTTPS via tailscale serve on :8443") is superseded — see 17-caddy.md. The "Bitwarden clients require HTTPS" reasoning still holds; it's just Caddy supplying the cert now, on :443 with a clean hostname.

Companion to 03-tailscale.md (transport), 17-caddy.md (the HTTPS front-end, shared with Radicale and everything else) and 05-backups.md (in the nightly backup since May 2026 — the DB via the SQLite online-.backup pattern, same as Beszel and Navidrome; the load-bearing files via rsync; never a raw filesystem copy of the live .sqlite3; the vault DB dump is on the off-site drive too).


Overview

Single-container Docker stack:

Container Image Purpose
vaultwarden vaultwarden/server:${VAULTWARDEN_VERSION} Bitwarden-compatible server: web vault, REST API, identity, icons, and WebSocket live-sync — all on one port

Vaultwarden is only the server. Every client is the official Bitwarden software pointed at this server's URL: the Bitwarden desktop app (Windows/macOS/Linux), the browser extensions (Chrome/Edge/Firefox), the iOS/Android apps, and the bw CLI. There is no separate "Vaultwarden client" — and anything in an app store calling itself one is unofficial. The only Vaultwarden-served UI is the web vault at the HTTPS URL below, used in a browser for account creation, bulk import, and the password-health reports.

One endpoint, used by everything:

Endpoint Used by Why
https://vault.z2mini.gabrielgabrie.com/ (TLS via the Caddy reverse proxy) iOS app, Windows desktop app, browser extensions, the web vault The Bitwarden mobile app flatly refuses a non-HTTPS self-hosted server, and the web vault's crypto (PBKDF2 key derivation) needs a secure context. Caddy fronts the container with a real auto-renewing Let's Encrypt cert (ACME DNS-01 via Cloudflare), so clients connect with no trust prompts. (Pre-Caddy this was a dedicated tailscale serve --https=8443 listener — Radicale owned :443; with Caddy each service gets its own subdomain on :443, so the :8443 workaround is gone.)

The container itself listens on 127.0.0.1:8080 only — nothing reaches it except Caddy, which runs on the host (network_mode: host). Not exposed to the public internet. Vault contents are end-to-end encrypted client-side with a key derived from the master password; the server (and anyone with root on the box) stores only ciphertext.


Design decisions

Decision Reasoning
Vaultwarden over official Bitwarden self-hosted Vaultwarden is a Rust reimplementation of the Bitwarden server API — idles at ~10–15 MB RAM, single container, SQLite. The official "unified" self-host image is heavier (~1 GB+, bundled DB engine) and gates a few features behind a license key. Both use the identical clients. Vaultwarden also enables Bitwarden's "premium" features (TOTP-in-vault, attachments, Sends, password-health reports) for free, which matters here. Same lightweight-single-binary pattern as Beszel and Radicale.
Vaultwarden over KeePassXC + a .kdbx file on the Samba share KeePass-family is the "files-as-truth" purist option (the encrypted .kdbx is the database, rsync-safe, server-less) and matches the Radicale philosophy — but it loses on iOS: third-party iOS clients, sync-conflict risk on simultaneous edits, and clunkier browser autofill. The requirement was "integrates with iOS nicely"; Bitwarden's iOS app + system AutoFill + Face ID is on par with iCloud Passwords, KeePass isn't.
Image: vaultwarden/server (Debian variant, the upstream :latest default) Most-tested variant. The -alpine tag is smaller (~50 MB vs ~180 MB) but the Debian one is what upstream docs assume; sticking with it keeps the compose close to upstream-verbatim.
Runs as user: "1000:1000" (the host's gabriel), not the image-default root Two wins: files under data/ stay gabriel-owned (clean backup, no sudo), and a non-root process on a high port needs no Linux capabilities at all — so cap_drop: ALL works. The image's start.sh just execs the binary (no privilege-dropping or chown), so running as non-root "just works". The original root-default run crash-looped with PermissionDenied writing rsa_key.pem precisely because cap_drop: ALL strips CAP_DAC_OVERRIDE and root then can't write into a gabriel-owned directory.
ROCKET_PORT=8080 (not the image default 80) A non-root, capability-less process can't bind a port below 1024. Moving to 8080 removes the need for CAP_NET_BIND_SERVICE and lets the cap_drop: ALL + user: 1000:1000 combination stand.
HTTPS via tailscale serve on port :8443 Bitwarden clients require HTTPS for self-hosted; the web vault's PBKDF2 needs a secure context. Radicale already owns https://z2mini.elk-kanyu.ts.net/ (:443), so a second listener: tailscale serve --bg --https=8443 http://127.0.0.1:8080. The cert is the same tailnet-hostname Let's Encrypt cert (auto-renewing, zero maintenance). A subpath on :443 (e.g. /vault) was the alternative — rejected as the fragile option (Vaultwarden subpath support exists but is finicky with some clients, and Radicale's CalDAV discovery wants root).
Container bound to 127.0.0.1:8080 only — not also the tailnet IP Unlike Radicale (which exposes plain HTTP on the tailnet interface for Thunderbird/scripts), nothing here needs direct HTTP access — every client uses the :8443 HTTPS endpoint. tailscale serve runs on the host and reaches 127.0.0.1 fine. Smaller surface.
Tailscale-only, no public exposure Same posture as Immich, Navidrome, Homepage, Beszel, Radicale. The vault is reachable only from devices on the tailnet (and the iPhone needs the Tailscale app connected to sync — the vault is cached encrypted on-device, so AutoFill of existing entries works offline; only sync/new items need the tunnel).
SIGNUPS_ALLOWED flipped true → create the one account → false Single-user box. Registration was open for the ~2 minutes it took to create the account, on a private tailnet; closed immediately after. To add another account later, flip it back, register, flip it off again.
Admin panel (ADMIN_TOKEN) left disabled for now /admin is genuinely useful (server config, user list, force re-sync) but not essential for a single user. Documented-but-commented in .env; enable later by generating an Argon2 hash (docker run --rm -it vaultwarden/server /vaultwarden hash) and uncommenting.
SMTP left unconfigured Vaultwarden only needs email for invites, password hints, and email-2FA — none of which a single-user setup uses. If ever wired in, it gets its own Gmail app password (same revocation-independence reasoning as Beszel's).
Image pinning via ${VAULTWARDEN_VERSION} in .env, never :latest Updates are deliberate (docker compose pull + restart), same as every other stack.
restart: unless-stopped (not always) Survives reboots, but docker compose stop actually stops.
TOTP and passkeys consolidated into the vault (decided, not yet migrated) Vaultwarden's free TOTP-in-vault means one app for passwords + 2FA codes; Bitwarden can also be set as the iOS passkey provider (iOS Settings → AutoFill & Passwords) so new passkeys land in the vault. The "all eggs in one basket" trade is accepted given a strong master password (and the option to add 2FA to the vault login itself later). The standalone Bitwarden Authenticator app — which is TOTP-only and would separate codes from the vault — was considered and not used; if separation were ever wanted, Ente Auth is the better standalone pick. Moving off Microsoft Authenticator means re-enrolling 2FA per-site (it has no clean seed export); a few accounts that mandate the Microsoft app (school/work Conditional Access) stay on it.

What was considered and rejected

  • Official Bitwarden self-hosted ("unified" container) — Bitwarden's own single-container deploy. Same clients as Vaultwarden, but ~1 GB+ RAM, a bundled database engine, and a license key for a few features (free for personal use, but still a moving part). Heavier than the rest of the stack for no gain here.
  • KeePassXC (Windows) + KeePassium/Strongbox (iOS) + .kdbx over Samba/WebDAV — the server-less, files-as-truth option; matches Radicale's storage philosophy and is trivially rsync-safe. Rejected on iOS integration: third-party iOS apps, sync-conflict risk if two devices edit at once, and browser autofill needs the desktop app running. The brief was "integrates with iOS nicely".
  • Passbolt / Padloc — Passbolt is team-oriented and heavy (PHP + MySQL); Padloc's iOS story is weak. Neither beats Vaultwarden for a single user.
  • Vaultwarden under a subpath on :443 (e.g. https://z2mini.elk-kanyu.ts.net/vault) — would have avoided a second HTTPS port. Rejected as the fragile path: Vaultwarden's subpath support is documented-but-finicky with some clients, and Radicale's CalDAV principal discovery expects to live at /. A dedicated :8443 listener is robust.
  • -alpine image variant — smaller, but the Debian variant is the upstream :latest default and what the wiki examples assume; not worth the deviation.
  • Admin panel enabled from day one — deferred, not rejected. Low cost to add later; not needed to stand the service up.
  • Cloud-Mac rental (MacinCloud / Scaleway / AWS EC2 Mac) for the iCloud Passwords export — attempted 2026-05-11 (MacinCloud 48-hour managed-server trial). Apple's anti-fraud blocks iCloud sign-in from datacenter IPs: the device-passcode verification step failed repeatedly with "There was an error verifying the passcode on your iPhone" despite a correct passcode. Abandoned. The migration was eventually done on a trusted personal MacBook — see the migration section. (Apple gates bulk export of iCloud Passwords to macOS only — there's no iOS or iCloud-for-Windows export, and no web export on iCloud.com. The cloud-Mac path remains a dead end for anything iCloud-related; don't re-suggest it.)
  • The standalone Bitwarden Authenticator app — a TOTP-only companion app that keeps codes outside the password vault. Not used: Vaultwarden's built-in TOTP-in-vault is free and the goal was consolidation, not separation. If separation is ever wanted, Ente Auth (open-source, clean import/export) is the better choice.

Install

Directory layout

/data/docker/vaultwarden/
├── docker-compose.yml   ← stack definition, version-pinned, hardened
├── .env                 ← mode 600 — version pin, DOMAIN, SIGNUPS flag, commented-out admin/push/SMTP
└── data/                ← owned 1000:1000 (gabriel); created by the container on first boot
    ├── db.sqlite3       ← THE database (logins, TOTP secrets, passkeys, folders, org data) — small
    ├── db.sqlite3-shm   ← SQLite shared-memory (WAL mode)
    ├── db.sqlite3-wal   ← SQLite write-ahead log (WAL mode)
    ├── rsa_key.pem      ← JWT signing key — load-bearing: losing it invalidates every session
    ├── attachments/     ← file attachments on vault items (appears once any exist)
    ├── sends/           ← Bitwarden Send payloads (appears once any exist)
    ├── icon_cache/      ← cached site favicons (rebuildable)
    ├── tmp/             ← transient upload staging
    └── config.json      ← present only if the admin panel is used; overrides .env at runtime

The image runs as root by default; this stack overrides it to 1000:1000 so everything in data/ is gabriel-owned — no sudo needed for the install, and backups read it directly.

docker-compose.yml

Mirrors the upstream Vaultwarden example compose (wiki: Using Docker Compose) except for these intentional customizations:

  1. Image pinned via ${VAULTWARDEN_VERSION} from .env — never :latest.
  2. Runs as user: "1000:1000" instead of the image-default root (see the design decision above) — so cap_drop: ALL is viable and data/ stays gabriel-owned.
  3. ROCKET_PORT=8080 instead of the image default 80, so a non-root, capability-less process can bind it.
  4. Port published on 127.0.0.1 only — nothing needs direct tailnet access.
  5. TZ pinned to America/Toronto; no-new-privileges, cap_drop, resource limits, and a /alive healthcheck added.
  6. Bind-mount anchored under /data/docker/vaultwarden/.
name: vaultwarden

services:
  vaultwarden:
    container_name: vaultwarden
    image: vaultwarden/server:${VAULTWARDEN_VERSION}
    restart: unless-stopped
    user: "1000:1000"
    env_file: .env
    environment:
      ROCKET_PORT: 8080
      TZ: America/Toronto
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    deploy:
      resources:
        limits:
          memory: 256M
          pids: 100
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/alive || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - "/data/docker/vaultwarden/data:/data"

Why user: 1000:1000 and not the image default: the upstream image runs as root, which on a gabriel-owned bind mount needs CAP_DAC_OVERRIDE to write — and cap_drop: ALL removes exactly that, so a root container crash-loops with PermissionDenied creating rsa_key.pem. Running as the directory owner (uid 1000) sidesteps it entirely: no capability is needed to write your own files or bind a high port, so the hardened cap_drop: ALL stands and the data stays owned by the human.

.env

# Vaultwarden configuration — full reference:
#   https://github.com/dani-garcia/vaultwarden/blob/main/.env.template
# Anything also settable in the /admin UI gets overridden by data/config.json once the
# admin panel has been used (the admin panel is not enabled — see the bottom of this file).

# --- Version pin. Verify current stable before bumping: ---
#   curl -s 'https://hub.docker.com/v2/repositories/vaultwarden/server/tags/?page_size=15&ordering=last_updated' \
#     | python3 -c 'import sys,json; [print(t["name"]) for t in json.load(sys.stdin)["results"]]'
VAULTWARDEN_VERSION=<set at install time>

# --- Public origin. MUST exactly match the URL clients connect to (HTTPS via
#     `tailscale serve` on port 8443). Drives attachment download URLs, email links,
#     and the WebAuthn / passkey relying-party ID. ---
DOMAIN=https://vault.z2mini.gabrielgabrie.com

# --- Registration is CLOSED. To register another account: set this to true,
#     `docker compose up -d`, register, set back to false, `docker compose up -d` again. ---
SIGNUPS_ALLOWED=false

# WebSocket live-sync for browser/desktop clients is on by default (ENABLE_WEBSOCKET=true).

# --- OPTIONAL follow-ups, intentionally NOT enabled: ---
# Admin panel at /admin — generate the Argon2 hash, then paste it here (quoted):
#   docker run --rm -it vaultwarden/server /vaultwarden hash
# ADMIN_TOKEN='$argon2id$v=19$...'
#
# Instant push to the Bitwarden iOS app — register (free) at https://bitwarden.com/host/, then:
# PUSH_ENABLED=true
# PUSH_INSTALLATION_ID=...
# PUSH_INSTALLATION_KEY=...
#
# Outbound email (invites / password hints / email-2FA) — unused on a single-user box; left unset.

.env is mode 600. It currently holds no live secret — only a version pin, the public domain, and a flag — but it's the natural home for ADMIN_TOKEN and the push keys if those get added, so it's locked down from the start.

Phase 1 — directories and the container

No sudo needed: /data/docker/ is gabriel-owned and the container runs as gabriel (uid 1000).

mkdir -p /data/docker/vaultwarden/data
cd /data/docker/vaultwarden
# write docker-compose.yml and .env with the contents above, then:
chmod 600 .env

docker compose config            # validate YAML + variable resolution
docker compose pull              # ~180 MB (Debian variant)
docker compose up -d
sleep 20 && docker compose ps     # expect: Up X seconds (healthy)
docker compose logs --tail 30 vaultwarden
# expect: "Private key 'data/rsa_key.pem' created correctly"
#         "Rocket has launched from http://0.0.0.0:8080"

If the container crash-loops with Error creating private key 'data/rsa_key.pem' ... PermissionDenied — the compose is missing user: "1000:1000" (or data/ isn't writable by uid 1000). See the design note above.

Phase 2 — HTTPS via Caddy (required for every client)

Bitwarden clients refuse a non-HTTPS self-hosted server, and the web vault's crypto needs a secure context. The Caddy reverse proxy serves https://vault.z2mini.gabrielgabrie.com with a real auto-renewing Let's Encrypt cert (ACME DNS-01 via Cloudflare), and the container is bound to 127.0.0.1:8080 only (Caddy reaches it there).

Historical note: this was originally a dedicated sudo tailscale serve --bg --https=8443 http://127.0.0.1:8080 listener (port :8443 because Radicale owned :443). That listener was retired in May 2026 when Caddy became the single ingress — with Caddy, Vaultwarden gets its own subdomain on :443 instead.

The Caddyfile block (already present — see 17-caddy.md):

vault.z2mini.gabrielgabrie.com {
    log
    reverse_proxy 127.0.0.1:8080
}

End-to-end check (cert must verify; /alive returns 200):

curl -fsS -o /dev/null -w 'HTTP %{http_code}, cert verify %{ssl_verify_result} (0=OK)\n' \
  https://vault.z2mini.gabrielgabrie.com/alive

Recovery: the HTTPS front-end is the Caddy stack under /data/docker/caddy/ (Caddyfile + .env Cloudflare token) — that is under /data/docker/ and folds into the nightly backup. After a Z2 rebuild, bring up the Caddy stack (docker compose build && up -d); its Caddyfile already has Vaultwarden's block. (No more tailscale serve re-init step.)

Phase 3 — create the single account, then close registration

The account can only be created over the HTTPS endpoint (the web vault's PBKDF2 key derivation needs a secure context — http://z2mini:8080 is not one).

  1. From a tailnet device's browser, open https://vault.z2mini.gabrielgabrie.com/ — the Bitwarden web-vault login page, valid cert.
  2. Create account. Email is just an identifier here (no email verification configured). The master password is the one secret that lives nowhere else — not in this vault, not in iCloud Passwords. Lose it and the vault is unrecoverable by design. Record it somewhere physical.
  3. Log in once to confirm.
  4. Set SIGNUPS_ALLOWED=false in .envdocker compose up -d (recreates the container) → docker compose exec vaultwarden printenv SIGNUPS_ALLOWED should print false.

Confirm the account exists at the DB level:

docker run --rm -v /data/docker/vaultwarden/data:/data:ro --entrypoint sh keinos/sqlite3 \
  -c 'sqlite3 -header -column /data/db.sqlite3 "SELECT email, created_at FROM users;"'

(Account on this server: gabrielgabrie99@gmail.com, created 2026-05-10.)


Clients

Server URL for every client: https://vault.z2mini.gabrielgabrie.com (with the port; publicly-trusted cert, no warnings). Only ever set the base "Server URL" — Vaultwarden serves the API / identity / icons / notifications endpoints from that one origin, so leave the individual sub-URL fields blank.

Windows

  • Bitwarden Desktop (bitwarden.com/download or Microsoft Store). On the login screen → region dropdown → Self-hostedServer URL = the URL above → log in with email + master password. Enable Windows Hello unlock in app settings if wanted.
  • Browser extension (Chrome/Edge/Firefox — "Bitwarden Password Manager"). Open the extension → region dropdown → Self-hosted → same Server URL → log in. This replaces the iCloud Passwords browser extension (autofill, save-on-submit).

iPhone

  • Bitwarden Password Manager (App Store). On the login screen → region selector at the top → Self-hosted → Server URL = the URL above → log in.
  • In-app Settings → enable Unlock with Face ID.
  • iOS Settings → General → AutoFill & Passwords → turn on Bitwarden (during the parallel run, leave iCloud Passwords on too).
  • The iPhone needs the Tailscale app connected to sync. The vault is cached encrypted locally, so AutoFill of existing entries works offline; only sync / new items need the tunnel.

Passkey provider (going forward)

iOS Settings → General → AutoFill & Passwords lets you choose which app provides passkeys (default: iCloud Passwords). Switching it to Bitwarden sends new passkeys into the vault. Passkeys already created in iCloud can't be exported (by design) — they stay in iCloud, or get re-created on sites that allow a second passkey. So it's "from now on, Bitwarden", not a clean migration.

TOTP (going forward)

Store a site's TOTP secret inside its login entry (paste the otpauth:// URI or the base32 secret when adding/editing the item); Bitwarden then autofills the 6-digit code alongside the password. This is a paid feature in Bitwarden's cloud — free on Vaultwarden. Migrating off Microsoft Authenticator means re-enrolling 2FA on each site (it has no clean seed export): site security settings → remove the old authenticator → add a new one → scan the fresh QR into Bitwarden. Do it gradually. Accounts that mandate the Microsoft Authenticator app specifically (school/work Conditional Access, number-matching push) stay on it.


Migration from iCloud Passwords — complete (2026-05-22)

Status: done. The ~400 logins are in Vaultwarden. iCloud Passwords AutoFill is off on iOS; Bitwarden is the primary password manager on iPhone + MacBook + Windows laptop (desktop app + browser extension). All new entries go to Vaultwarden only.

The path that worked: Gabriel's own MacBook (the prior MacinCloud cloud-Mac attempt had failed — see Considered/Rejected; Apple's anti-fraud blocks iCloud sign-in from datacenter IPs). With a trusted personal Mac it's a five-minute procedure:

  1. On the MacBook, signed into the Apple ID — open Passwords (macOS Sequoia) or System Settings → Passwords (Sonoma/Ventura). Confirm entries are populated.
  2. File → Export All Passwords to File… → accept the unencrypted-CSV warning → save. (Authenticates with the Mac login password.)
  3. Browser → https://vault.z2mini.gabrielgabrie.comTools → Import data → File format Apple Passwords (csv) (that exact option, not generic CSV) → choose the file → Import data.
  4. Spot-check: a sample of logins, a folder or two, a TOTP entry, a saved card.
  5. Securely delete the CSV — delete the file, empty the Trash. It's plaintext with every password in the clear.
  6. iOS Settings → General → AutoFill & Passwords → turn Bitwarden on and toggle Passwords (Apple's iCloud entry) off for AutoFill.

What did NOT come across (expected):

  • Wi-Fi passwords — never in the export. They stay synced via iCloud Keychain across Apple devices and that's fine; iCloud Keychain (the service) should stay enabled even after this migration, just with the Passwords entries deleted from it.
  • Passkeys — can't be exported by anyone, by design. The 8 existing passkeys remain in iCloud Passwords. Re-creation in Vaultwarden is per-site (see "Still to do" below); leaving Bitwarden as the iOS passkey provider sends new ones into the vault automatically.

What's still to do (low-pressure, gradual):

  1. Delete the iCloud Passwords entries — bulk action from the MacBook: Passwords app → Cmd+A → Delete → authenticate. Syncs the deletions to all Apple devices within ~a minute. Do not disable iCloud Keychain itself — keep that on for Wi-Fi password sync. Wait a few days of confirmed Bitwarden-only use before doing this if you want a safety-net window.
  2. Re-create the 8 passkeys in Vaultwarden, one site at a time. For each: in iOS Settings → General → AutoFill & Passwords confirm Bitwarden is the passkey provider; on the site, register a new passkey (it lands in Vaultwarden); then — and only then — delete the iCloud passkey for that site. Doing it in that order avoids any chance of lockout.
  3. TOTP — re-enroll 2FA per-site off Microsoft Authenticator. No clean seed export from MS Authenticator. Per site: security settings → remove old authenticator → add a new one → scan the fresh QR into the matching Bitwarden login entry. MS Authenticator stays only for accounts that mandate the Microsoft app (Conestoga / Conditional Access / number-matching push). Gradual.
  4. Run the Vaultwarden password-health report — web vault → reused / weak / HIBP-exposed (a paid Bitwarden-cloud feature, free here) → rotate the genuinely-bad ones. This (not deleting stale entries) is the high-value cleanup.

Optional follow-up: SSH keys in the vault. Bitwarden's SSH key item type + the Bitwarden desktop SSH-agent integration lets the vault store and serve SSH private keys to the OS's ssh client. Useful for the GitHub key (cross-device sync between MacBook and Windows laptop, vault unlock per use). Doesn't apply to ssh gabriel@z2mini (that's Tailscale SSH, no key), doesn't help iOS (no usable agent integration), and doesn't help headless/CI SSH (still needs a bare key file). Wire it up when ready: Bitwarden Desktop → Settings → SSH agent → enable → point the OS's SSH client at the Bitwarden agent socket.


Operations

Start / stop / pull updates

cd /data/docker/vaultwarden
docker compose up -d           # start (or recreate after a pull / .env change)
docker compose stop            # stop the container
docker compose down            # stop and remove (data preserved under data/)

# Updates: verify the current stable tag, bump VAULTWARDEN_VERSION in .env, then:
docker compose pull
docker compose up -d
docker compose logs --tail 20 vaultwarden     # confirm clean start

Logs

docker compose logs -f --tail 50 vaultwarden

Failed logins, sync requests, and WebSocket connections appear here. Useful for diagnosing client connection issues.

Health and resource use

docker compose ps                                                  # STATUS should say (healthy)
docker stats --no-stream vaultwarden                               # idles ~10–15 MiB / 256 MiB
curl -fsS https://vault.z2mini.gabrielgabrie.com/alive               # returns a timestamp

Inspect the vault at the DB level (metadata only — contents are encrypted)

docker run --rm -v /data/docker/vaultwarden/data:/data:ro --entrypoint sh keinos/sqlite3 -c '
  sqlite3 -header -column /data/db.sqlite3 "SELECT email, updated_at FROM users;"
  sqlite3 -header -column /data/db.sqlite3 "SELECT count(*) AS n_items FROM ciphers WHERE deleted_at IS NULL;"
  sqlite3 -header -column /data/db.sqlite3 "SELECT substr(uuid,1,8) AS id, atype, length(data) AS enc_bytes, created_at FROM ciphers;"
  sqlite3 -header -column /data/db.sqlite3 "SELECT name, updated_at FROM devices;"
'

ciphers.atype: 1 = login, 2 = secure note, 3 = card, 4 = identity. The data column is the encrypted blob — the server can't read item names, usernames, passwords, TOTP secrets, or passkeys; only that an item exists. This is the end-to-end encryption working: shell/root on the box is not enough to read the vault.

Disk usage

du -sh /data/docker/vaultwarden/data/

The DB is small (low single-digit MB even with hundreds of entries); attachments and Sends add whatever you put in them.

Connecting from the server itself

Same gotcha as Immich, Navidrome, Homepage, Beszel, Radicale: scripts running on z2mini hit the container directly at http://127.0.0.1:8080 (it's bound to localhost). The https://vault.z2mini.gabrielgabrie.com URL also works from the server. http://z2mini:8080 (short hostname) does not — the container isn't bound to the tailnet interface.

Enable the admin panel (optional, later)

docker run --rm -it vaultwarden/server /vaultwarden hash    # prompts twice, prints an $argon2id$ hash
# add to .env:  ADMIN_TOKEN='$argon2id$v=19$...'
docker compose up -d
# then visit https://vault.z2mini.gabrielgabrie.com/admin

Manual backup ad-hoc

The nightly backup-files.sh already covers Vaultwarden: it captures db.sqlite3 via the host sqlite3 ".backup" into /mnt/backup/current/db-dumps/vaultwarden-db.sqlite3, and rsyncs rsa_key.pem + attachments/ + sends/ + config.json + .env + docker-compose.yml into /mnt/backup/current/service-config/vaultwarden/ (the live data/db.sqlite3 and data/tmp/ are excluded from that rsync). To force a fresh copy out-of-cycle, just run ~/scripts/backup-files.sh.

Doing it by hand (e.g. before a risky change), since SQLite is in WAL mode and live — never raw-rsync db.sqlite3 while the container is running (same rule as Beszel and Navidrome) — use the online-backup API:

# Online backup of the DB to a single consistent file:
docker run --rm -v /data/docker/vaultwarden/data:/data --entrypoint sh keinos/sqlite3 \
  -c 'sqlite3 /data/db.sqlite3 ".backup /data/db-backup.sqlite3"'
# copy the dump + the load-bearing files out:
rsync -av \
  /data/docker/vaultwarden/data/db-backup.sqlite3 \
  /data/docker/vaultwarden/data/rsa_key.pem \
  /data/docker/vaultwarden/data/attachments \
  /data/docker/vaultwarden/data/sends \
  /data/docker/vaultwarden/data/config.json \
  /data/docker/vaultwarden/.env \
  /mnt/backup/current/service-config/vaultwarden/ 2>/dev/null
rm /data/docker/vaultwarden/data/db-backup.sqlite3

Restore is the reverse: stop the container, drop the DB dump in as data/db.sqlite3 (remove any stale -shm/-wal), restore rsa_key.pem + attachments/ + sends/ + config.json, fix ownership (chown -R 1000:1000 /data/docker/vaultwarden/data), docker compose up -d. See 08-recovery.md → Step 6b.


Backup considerations

Vaultwarden is in the nightly backup (since May 2026 — see 05-backups.md):

  • data/db.sqlite3 — the vault (logins, TOTP secrets, passkeys, folders). Captured via the host sqlite3 "<live db>" ".backup '<dest>'" into /mnt/backup/current/db-dumps/vaultwarden-db.sqlite3. Online-backup pattern only, never a filesystem copy of the live WAL-mode DB (or its -wal/-shm) — exactly like Beszel's hub DB and Navidrome's navidrome.db. The dump is also on the off-site T5 (it's in db-dumps/).
  • data/rsa_key.pem — the JWT signing key. Load-bearing: lose it and every existing session is invalidated (everyone has to log in again — recoverable, but jarring). Tiny; rsync'd into service-config/vaultwarden/.
  • data/attachments/, data/sends/ — file attachments and Send payloads. Pure rsync, into service-config/vaultwarden/.
  • data/config.json — only exists if the admin panel was used; if present it overrides .env, so it's rsync'd along with the rest.
  • .env (mode 600 — version pin + DOMAIN +, if added, ADMIN_TOKEN/push keys) + docker-compose.yml — rsync'd into service-config/vaultwarden/. DOMAIN=https://vault.z2mini.gabrielgabrie.com is load-bearing — it's the WebAuthn/passkey relying-party ID; restore it exactly.

What's not backed up: data/db.sqlite3 itself is excluded from the service-config/vaultwarden/ rsync (it's in db-dumps/ instead, as the online-backup dump); data/icon_cache/ (rebuildable site favicons) and data/tmp/ (transient upload staging) are skipped.

The HTTPS front-end is the Caddy stack (/data/docker/caddy/) — under /data/docker/, so it's in the nightly backup (service-config/caddy/). After a Z2 rebuild, bring up the Caddy stack; its Caddyfile already has Vaultwarden's vault.z2mini.gabrielgabrie.com block. See 17-caddy.md. (No more tailscale serve re-init step.)

Restore: stop the container, copy db-dumps/vaultwarden-db.sqlite3 in as data/db.sqlite3 (remove any stale -wal/-shm), restore rsa_key.pem + attachments/ + sends/ + config.json + .env + compose from service-config/vaultwarden/, fix ownership (chown -R 1000:1000 /data/docker/vaultwarden/data), docker compose up -d. See 08-recovery.md → Step 6b.


Troubleshooting

Container crash-loops on start with Error creating private key 'data/rsa_key.pem' ... PermissionDenied:

  • The compose is missing user: "1000:1000", so the container runs as root, and cap_drop: ALL has stripped CAP_DAC_OVERRIDE — root can't write into the gabriel-owned data/ directory. Add user: "1000:1000" and recreate. (Alternatively chown -R 0:0 data/ would also work, but then the data is root-owned — running as uid 1000 is the cleaner fix.)

Container exits immediately with no obvious error:

  • Check docker compose logs vaultwarden. Most common: a malformed .env (e.g. an unquoted ADMIN_TOKEN whose $ characters get mangled — quote it with single quotes), or DOMAIN not parseable as a URL.

docker compose ps shows (unhealthy):

  • The healthcheck curls http://127.0.0.1:8080/alive inside the container. If Vaultwarden launched on a different port (custom ROCKET_PORT not matching), or ROCKET_ADDRESS was set to something other than 0.0.0.0, the check fails while the service may still be reachable. Confirm docker compose logs shows Rocket has launched from http://0.0.0.0:8080.

Bitwarden mobile app: "Trouble connecting" / refuses the server URL:

  • The app requires HTTPS for self-hosted. Confirm the URL is https://vault.z2mini.gabrielgabrie.com (not http://, no port), the iPhone has Tailscale connected (and can resolve vault.z2mini.gabrielgabrie.com — the Tailscale global resolvers handle that), and docker compose -f /data/docker/caddy/docker-compose.yml ps shows caddy healthy. From a laptop, curl -fsS https://vault.z2mini.gabrielgabrie.com/alive should return HTTP 200 with cert verify 0.

Web vault account creation fails with a crypto / "secure context" error:

  • You opened http://127.0.0.1:8080 (or another non-HTTPS URL). The web vault's PBKDF2 needs a secure context. Use https://vault.z2mini.gabrielgabrie.com/.

Browser/desktop client connects but won't sync, or sync seems stale:

  • WebSocket live-sync (ENABLE_WEBSOCKET=true, on by default) goes through Caddy's HTTPS proxy, which upgrades WebSocket connections automatically. A manual pull (the sync button) always works regardless. If WebSocket is the problem, check docker compose logs for /notifications/hub connection lines.

Can't reach the server from z2mini itself:

  • Use http://127.0.0.1:8080 (the container binds loopback) or https://vault.z2mini.gabrielgabrie.com (via Caddy). http://z2mini:8080 and http://100.67.235.68:8080 no longer work. From other tailnet devices, the only way in is https://vault.z2mini.gabrielgabrie.com.

iCloud Passwords export: "There was an error verifying the passcode on your iPhone" (during the migration, on a Mac):

  • Apple's anti-fraud, triggered by signing in from an unfamiliar / datacenter IP. On the iPhone, approve the "Apple Account Sign-In Requested" prompt first and look for a 6-digit code to enter instead; or on the Mac sign-in screen use "Didn't get a verification code?" → send a text-message code to the trusted phone number (this bypasses the device-passcode step); or refresh the iPhone's connection (airplane mode off/on, ensure correct date/time) and retry; or sign in via the App Store / Messages app on the Mac instead of System Settings. If none work, the Mac is too "untrusted" — use a different (physical, personally-used) Mac. This is why the cloud-Mac route was abandoned.