Vaultwarden Guide
Vaultwarden password manager — single-container deployment behind the shared edge Traefik, backed by the shared Postgres.
Vaultwarden is the self-hosted password manager (a Rust, Bitwarden-compatible server). It runs as a single container behind the shared edge Traefik, using the shared Postgres. This page covers the VPS deployment as it was stood up; a dev bring-up is identical except for env values and domain.
Source Of Truth
- Active compose:
runtime/stacks/infrastructure/vaultwarden/docker-compose.yml - VPS env:
runtime/environments/vps/infrastructure/vaultwarden/.env - Stack README:
runtime/stacks/infrastructure/vaultwarden/README.md
There is no runtime/scripts/infrastructure/vaultwarden.{sh,bat} launcher; Vaultwarden is driven directly with docker compose.
Architecture
| Container | Image | Port | Role |
|---|---|---|---|
vaultwarden | vaultwarden/server:1.36.0 (pinned) | 80 | Web vault + API + WebSocket notifications |
- One internal HTTP listener on
:80serves the web vault, the API, and live-sync WebSocket. The legacy separate3012port is gone on modern images. - Networks: joins
proxy(Traefik) andpostgres-network(the DB). - TLS terminates at Traefik (
websecure+ Let's Encrypt); the backend is plain HTTP. - Persistent state lives in the
vaultwarden-datavolume mounted at/data(RSA key, attachments, sends, icon cache,config.json). - The image is pinned and Watchtower is disabled — a password manager should never auto-update under you.
Why native env names
Vaultwarden reads its own native variable names (DATABASE_URL, DOMAIN, ADMIN_TOKEN, …), not the VAULT_* convention used elsewhere in this repo. The env file is therefore written in Vaultwarden's names, with the raw DB credential parts kept as reference comments. Two consequences:
DATABASE_URLembeds the DB password percent-encoded ($→%24,&→%26,%→%25,*→%2A). The percent-encoding is only for the URL — the actual Postgres role keeps the raw password.- Compose
${...}interpolation does not read the serviceenv_file:, soVAULT_HOST(used in the TraefikHost()label) must come from--env-fileon the CLI.
Prerequisites
- Shared
proxynetwork and the edge Traefik running (see Edge). - Shared Postgres running on
postgres-network(see Databases). - A DNS record for the external domain (
vault.perspective-v.com) resolving to the host.
Setup
Unlike Zitadel, Vaultwarden does not create its own database or role — provision them first.
-
Provision the database (one-time, against the
postgrescontainer; the raw, un-encoded password is used here as a SQL literal):docker exec -it postgres psql -U postgresCREATE ROLE vaultwarden_db_usr WITH LOGIN PASSWORD '<raw-db-password>'; CREATE DATABASE vaultwarden_db OWNER vaultwarden_db_usr; -
Generate the admin token for the
/adminpanel and paste the$argon2id$...string (single-quoted) intoADMIN_TOKENin the env file. Leave it empty to disable the panel.docker run --rm -it vaultwarden/server:1.36.0 /vaultwarden hashvaultwarden hashneeds a real TTY. In a non-interactive shell, generate an equivalent Argon2id PHC string with any argon2 tool — Vaultwarden verifies against the parameters embedded in the string, so the exactm/t/pvalues need not match thebitwardenpreset. -
Bring the stack up (the
--env-fileis required so${VAULT_HOST}resolves in the Traefik label):cd runtime/stacks/infrastructure/vaultwarden docker compose --env-file ../../../environments/vps/infrastructure/vaultwarden/.env up -d -
Register the first user at
https://vault.perspective-v.com/whileSIGNUPS_ALLOWED=true, then tighten signups (see Signup And Org-Ownership Policy) and re-run the bring-up command to apply.
SMTP And Deliverability
SMTP is configured from the same mxrouting mailbox the Zitadel stack uses (implicit TLS on port 465 → SMTP_SECURITY=force_tls):
| Vaultwarden var | Value |
|---|---|
SMTP_HOST / SMTP_PORT | fusion.mxrouting.net / 465 |
SMTP_SECURITY | force_tls |
SMTP_FROM / SMTP_FROM_NAME | no-reply@perspective-v.com / Perspective-V Vault |
SMTP_USERNAME | no-reply@perspective-v.com |
Vaultwarden only contacts the SMTP server when it actually sends mail; a clean startup does not prove SMTP works. Test it from the /admin panel (Diagnostics → SMTP) or by triggering a verification email.
Outlook/Hotmail deliverability. Mail to consumer Outlook addresses was silently filtered even though mxrouting accepted it and SPF + DKIM both pass. Delivery to Gmail works. Root cause is Microsoft's aggressive filtering of a new sending domain combined with a missing DMARC record. The send path itself is correct.
Domain-auth status for perspective-v.com:
| Record | Status |
|---|---|
| SPF | ✅ v=spf1 include:mxroute.com -all (covers mxroute's sending IPs) |
| DKIM | ✅ selector x._domainkey.perspective-v.com published |
| DMARC | ❌ none — add _dmarc.perspective-v.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@perspective-v.com" to improve Outlook deliverability |
Creating Organizations
ORG_CREATION_USERS is unset, which means all users may create organizations (blank/all = allowed; none or an email allowlist would restrict it).
The "New organization" button is not on the main vault page in the current bundled web vault — it moved into the org/product switcher (top-left). If you can't find it, go straight to the route:
https://vault.perspective-v.com/#/create-organizationThis always works and lands on the Free plan (Vaultwarden patches out the cloud billing step). The button being hidden is an upstream web-vault UI condition, not a backend permission issue.
Validate
docker inspect -f '{{.State.Health.Status}}' vaultwarden # healthy
docker exec vaultwarden curl -fsS http://127.0.0.1:80/alive # RFC3339 timestamp
curl -s -o /dev/null -w "%{http_code}\n" https://vault.perspective-v.com/alive # 200The web vault loads at https://vault.perspective-v.com/ with a valid Let's Encrypt certificate; /admin prompts for the admin token.
Signup And Org-Ownership Policy
SIGNUPS_ALLOWED is currently open to bootstrap the first accounts. The intended end state is Option C — only @perspective-v.com users own organizations; they invite external members (gmail, hotmail, …). The mechanics below are confirmed against the Vaultwarden 1.36.0 source, and they determine exactly how far that intent can be enforced declaratively.
How the three settings actually behave
| Setting | Behavior (verified in source) |
|---|---|
SIGNUPS_DOMAINS_WHITELIST | Gates self-registration by the email's domain (exact match after @, lowercased, comma-separated). It overrides SIGNUPS_ALLOWED. Invited users bypass it entirely. |
INVITATIONS_ALLOWED | Org owners/admins can invite any email as a member, even one outside the whitelist and even when signups are off. |
ORG_CREATION_USERS | Who may create an organization. Blank/all = everyone, none = nobody, otherwise an exact, full-email allowlist (a@x.com,b@x.com). No domain wildcard — perspective-v.com as a value would match nobody. |
The invite half of Option C is fully native: INVITATIONS_ALLOWED=true + invited users bypass the domain whitelist. The org-ownership half is the catch — because ORG_CREATION_USERS takes exact emails only, "any @perspective-v.com user may own an org" cannot be expressed in one setting. That forces a choice between two enforcement levels:
Pragmatic (recommended)
SIGNUPS_ALLOWED=true
SIGNUPS_DOMAINS_WHITELIST=perspective-v.com
INVITATIONS_ALLOWED=true
# ORG_CREATION_USERS left blank (all registered users may create orgs)Only @perspective-v.com addresses can self-register, so in practice only perspective-v people obtain org-creating accounts; external users exist solely via invitation. Residual gap: an invited external member could also create their own organization, because ORG_CREATION_USERS=all applies to every existing account regardless of how it was created. Low risk (you control who gets invited) and zero ongoing maintenance.
Strict
SIGNUPS_ALLOWED=true
SIGNUPS_DOMAINS_WHITELIST=perspective-v.com
INVITATIONS_ALLOWED=true
ORG_CREATION_USERS=owner1@perspective-v.com,owner2@perspective-v.comOnly the explicitly listed emails can create organizations — an invited external member is structurally barred. Cost: the allowlist has no wildcard, so every new perspective-v owner must be added to ORG_CREATION_USERS by hand and the stack re-upped.
Open decision: pragmatic vs strict. Both pin self-registration to @perspective-v.com and allow external members by invite — that part is settled. The only undecided point is whether to also hard-restrict org creation to a maintained email allowlist (strict) or accept that any registered user may create one (pragmatic). Until chosen, signups remain fully open; apply either block above and re-run the bring-up command.
Maintenance
# Apply env changes (e.g. after editing signup policy or SMTP)
docker compose --env-file ../../../environments/vps/infrastructure/vaultwarden/.env up -d
# Update: bump the image tag in docker-compose.yml, then re-up
docker compose --env-file ../../../environments/vps/infrastructure/vaultwarden/.env up -d
# Stop (keeps data); add -v ONLY to also delete the vaultwarden-data volume
docker compose --env-file ../../../environments/vps/infrastructure/vaultwarden/.env downImportant Rules
- The image is pinned — updates are deliberate. Bump the tag, review the Vaultwarden release notes, then re-up.
- Never delete the
vaultwarden-datavolume without a backup — it holds the RSA key and all attachments/sends. Losing the RSA key invalidates sessions. - Changing
DOMAINafter users register invalidates WebAuthn/passkeys registered against the old origin. ADMIN_TOKENis single-quoted in the env file because the Argon2 PHC string contains$.