Home Blog

Self-hosting Vaultwarden, and the one path that ruins it

Vaultwarden is a Rust reimplementation of the Bitwarden server API, small enough to run comfortably on a 2 vCPU box alongside other services. It inherits Bitwarden's client-side encryption: the master password never reaches the server, the vault key is derived on the device, and the server only ever stores ciphertext plus a KDF-derived hash. A full server breach does not hand over your passwords.

That property is what makes self-hosting defensible. It is also easy to accidentally give it away, which is most of what this post is about.

The deployment

services:
  vaultwarden:
    image: vaultwarden/server:1.37.1
    container_name: vaultwarden
    restart: unless-stopped
    ports:
      # Loopback only; nginx terminates TLS and proxies to it.
      - "127.0.0.1:8082:80"
    volumes:
      - ./vaultwarden-data:/data
    environment:
      # Must match the public URL exactly or the clients break.
      - DOMAIN=https://vault.example.com
      - SIGNUPS_ALLOWED=false
      - SHOW_PASSWORD_HINT=false
      - WEB_VAULT_ENABLED=false

No ADMIN_TOKEN, so the admin panel does not exist. One fewer credential to leak on a box that is reachable from the internet.

nginx in front needs two things beyond the usual proxy headers: client_max_body_size 128m for attachments and full exports, and WebSocket upgrade headers, because /notifications/hub is how clients learn about changes made elsewhere. Without the upgrade headers, everything works except live sync, which is a confusing thing to debug later.

Do not put HTTP basic auth in front of it. The browser extension and mobile clients cannot answer that prompt.

Closing signups, and actually checking

SIGNUPS_ALLOWED=false is the setting. Verifying it is less obvious, because there are three registration paths and they do not all report the same way.

V=https://vault.example.com

curl -s -X POST "$V/identity/accounts/register" \
  -H 'Content-Type: application/json' \
  --data '{"email":"[email protected]","name":"p","masterPasswordHash":"x","key":"x","kdf":0,"kdfIterations":600000}'

curl -s -X POST "$V/identity/accounts/register/send-verification-email" \
  -H 'Content-Type: application/json' --data '{"email":"[email protected]","name":"p"}'

Both return Registration not allowed or user already exists. The third, /identity/accounts/register/finish, returns Registration is missing required parameters for a payload it cannot deserialise. That is a validation error raised before the signup check, so on its own it tells you nothing about whether signups are open. Reading the source, it delegates to the same register() handler that enforces CONFIG.is_signup_allowed(&email), so it is covered.

The check that settles it regardless of endpoint semantics:

sqlite3 /path/to/vaultwarden-data/db.sqlite3 'SELECT email FROM users;'

One more wrinkle: the web vault UI keeps showing a "Create account" link even with signups closed. It is cosmetic. The server refuses.

Turning off the web vault

This is the recommendation I would push hardest, and it is the one that costs you something.

The threat model of client-side encryption assumes the client is trustworthy. The browser extension and desktop apps ship their own code; they never download it from your server. The web vault does. So if someone gets code execution on your box, they can modify the JavaScript it serves and capture your master password the next time you log in through a browser. That converts a server compromise, which the encryption model is designed to survive, into total vault compromise.

- WEB_VAULT_ENABLED=false

After recreating the container, the root path is gone but the API is untouched:

GET /                                      404
GET /alive                                 200
GET /api/config                            200
POST /identity/accounts/prelogin/password  200
POST /identity/connect/token   (bad pw)     400

A 404 on / with a 200 on /alive is the healthy state. Extensions and desktop and mobile clients keep working exactly as before. What you lose is browser access from a machine where you have not installed a client, which for most people is rarer than it sounds.

The error message that tells you nothing

Here is the failure that cost me the most time, and the lesson generalises.

The Firefox extension refused to log in to the self-hosted server with "an unexpected error has occurred". Nothing else. I checked the obvious things: the certificate chain verified with return code 0, /alive and /api/config both returned 200, prelogin returned proper KDF parameters, and connect/token correctly rejected a deliberately wrong password. Every endpoint I tested by hand behaved perfectly.

The server log had the answer immediately:

POST /identity/accounts/prelogin/password  =>  404 Not Found

Current Bitwarden clients call /identity/accounts/prelogin/password as the first step of login. The version I had pinned did not implement that route. It 404s, and the client surfaces that as a generic error with no indication of which request failed.

Read the server log before touching anything else. Client-side error strings in this ecosystem are close to useless, and the server names the exact route.

The deeper mistake was mine: I had pinned the image version out of habit, the way you would pin a database to avoid an unwanted schema migration. But this is a service whose clients update themselves continuously, from browser stores and app stores you do not control. Pinning it far behind guarantees that eventually a client speaks an API your server does not. For a self-hosted server with auto-updating clients, staying current is the safer default.

Since schema migrations are one-way, back up before upgrading:

sqlite3 vaultwarden-data/db.sqlite3 ".backup '/tmp/vw.sqlite3'"
tar czf /path/to/backups/vaultwarden-manual-$(date -u +%Y%m%dT%H%M%SZ).tar.gz \
    -C /path/to vaultwarden-data
# then bump the tag, docker compose pull && up -d, and check:
docker logs vaultwarden 2>&1 | grep -iE 'error|panic|migrat'
sqlite3 vaultwarden-data/db.sqlite3 'SELECT COUNT(*) FROM ciphers;'

Two things about backups

If you back this up, and you should, two details are easy to get wrong.

Use sqlite3 .backup, not cp. The database runs in WAL mode. When I looked, there was a live 247 KB db.sqlite3-wal sitting next to the main file. Copying just the file captures a torn state and silently loses committed data. .backup takes a proper read lock without stopping the service.

Include rsa_key.pem. It signs the JWTs. Restore without it and every client is silently logged out, which looks like a much worse failure than it is.

And encrypt the backup before it leaves the machine. The vault items are already encrypted client-side, but the database also contains the master password hash, which is offline-crackable. That should not sit unencrypted in someone else's object storage.

Where it actually stands

Setting State
signups closed, verified against all three endpoints
web vault off
admin panel disabled, no token set
2FA TOTP enabled
KDF PBKDF2-SHA256, 600,000 iterations
exposure loopback only, nginx with TLS 1.2/1.3 and HSTS

The remaining upgrade is switching the KDF to Argon2id, a one-time client-side change, since it is materially harder to attack on GPUs than PBKDF2. Worth knowing that 2FA does nothing for that threat: an attacker cracking a stolen database never touches your API, so only the master password's strength matters there. 2FA protects the online path.

Is it safe?

Cryptographically, self-hosted and hosted Bitwarden are the same design. What differs is who runs it. The hosted service has a security team, audits, DDoS absorption and reliable backups.

For most people hosted is safer in practice, not because of the cryptography but because the failure mode of self-hosting is operational: backups you never took, and patches you never applied. Both of my problems above were exactly that. Self-hosting is a reasonable choice if you own those two things honestly.