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.