Self-Hosting Vaultwarden Without Trusting the Defaults
Vaultwarden gives you a Bitwarden-compatible password server in one container and about ten minutes. The defaults that make that possible leave org creation open, audit logging off, accounts on PBKDF2, and a rate limiter that trusts a header anyone can set.
Somebody with your email address and your reused password is trying it against forty sites right now. That is not a scare story, it is the business model of every infostealer log ever sold. A password manager is the only practical answer, and running your own means the vault ciphertext lives on hardware you can put your hands on.
It also means you are now the one who gets paged. Here is the whole server:
# /opt/vaultwarden/docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:1.37.2
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
ADMIN_TOKEN: '$$argon2id$$v=19$$m=65540,t=3,p=4$$SALT$$HASH'
volumes:
- ./data:/data
ports:
- "127.0.0.1:8080:80"
That is it. One container, one volume, four environment variables. It will work.
This is week one of Self Hosting Saturday, and Vaultwarden is the right place to start, because it is the service people most reliably set up once and then stop thinking about for a year. It is also the one holding every other credential you own. Everything below is checked against the source at tag 1.37.2, AGPL-3.0, released 22 August 2026 — not against what the settings sound like they do.
What You Are Actually Installing
Vaultwarden is not Bitwarden. It is an independent server written in Rust, started by Daniel García (dani-garcia), with Mathijs van Veluw (BlackDex) cutting a good share of recent releases, and it implements Bitwarden's client API. The official Bitwarden clients — browser extension, desktop, iOS, Android, CLI — talk to it without modification or patching. They do not know the difference.
What you gain is footprint, though less than the usual comparison claims. Bitwarden's standard self-hosted deployment is eleven containers — their own blog says so — and every deployment except one ships an MSSQL Express image by default. That is the version of this argument everybody repeats. It stopped being the whole picture in December 2025, when Bitwarden Lite (previously "unified") went generally available as a single container that will talk to SQLite, PostgreSQL, MySQL or MSSQL. If container count is your objection, Bitwarden has an answer to it now.
What is still true is the shape of the thing. Vaultwarden is one static binary plus the compiled web vault on debian:trixie-slim, defaulting to SQLite, with no licence server in the path and no feature gates. Bitwarden's own documentation positions Lite for personal use and home labs rather than business deployments. Pick on the merits, not on a container count from 2022.
What you inherit is the version treadmill. Vaultwarden trails the clients: 1.37.0's release notes require it for clients at 2026.7.0 or newer, 1.37.2's for 2026.8.0. Your phone auto-updates. Your server does not.
One thing worth being clear about, because guides gloss it: the encryption is not Vaultwarden's. Vault items are encrypted and decrypted in the client, with a key derived from your master password, and the server stores ciphertext it cannot read. Compromising the server does not hand over your passwords. It hands over encrypted blobs, metadata, and whatever an attacker can do with admin access — which, as the advisory list further down shows, is not nothing.
The Compose File, With the Dead Options Removed
Search for a Vaultwarden compose file and roughly half of what you find still contains this:
WEBSOCKET_ENABLED: "true"
WEBSOCKET_PORT: 3012
ports:
- "3012:3012"
Delete all of it. The separate WebSocket server was folded into the main Rocket process in 1.30.0 and the standalone port was removed outright in 1.31.0. WEBSOCKET_PORT no longer exists. The Dockerfile at 1.37.2 declares exactly one port:
VOLUME /data
EXPOSE 80
Real-time sync now lives on the same port as everything else, at /notifications/hub and /notifications/anonymous-hub, both mounted under the /notifications prefix in src/main.rs. ENABLE_WEBSOCKET defaults to true and there is no reason to touch it.
Here is the version worth typing, expanded from the four-variable stub above:
# /opt/vaultwarden/docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:1.37.2
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
# Registration: closed, invite-only. Read the whitelist section below
# before you add SIGNUPS_DOMAINS_WHITELIST to this block.
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
SIGNUPS_VERIFY: "true"
# Nobody but you creates organisations.
ORG_CREATION_USERS: "none"
# Audit logging for orgs is off by default. Turn it on, and set a
# retention window or the cleanup job never starts.
ORG_EVENTS_ENABLED: "true"
EVENTS_DAYS_RETAIN: "90"
# No password hints on a public instance.
PASSWORD_HINTS_ALLOWED: "false"
ADMIN_TOKEN: '$$argon2id$$v=19$$m=65540,t=3,p=4$$SALT$$HASH'
volumes:
- ./data:/data
ports:
# Bound to loopback. The reverse proxy is the only thing that reaches it.
- "127.0.0.1:8080:80"
Note what is absent. No LOGIN_RATELIMIT_*, because the defaults are already correct and writing them out invites getting them wrong. That section is further down, and it is the one most guides break.
HTTPS Is Not a Recommendation Here
You need HTTPS, and it is worth knowing that two separate mechanisms enforce that, because they fail differently.
The web vault needs a secure context for the browser's Web Crypto API. That is the browser's rule, not Bitwarden's and not Vaultwarden's, and nobody in the chain can waive it. Serve the vault over plain HTTP from anything other than localhost and the crypto calls fail — historically surfacing as Cannot read property 'importKey', which tells you nothing about the actual cause.
The desktop client is a separate and newer restriction. Bitwarden's clients repo added PM-25250 Prevent configuration and access of self-hosted URLs over http, which rejects HTTP self-hosted URLs outright, permitting them only in development mode. So "the app worked over HTTP last year" is a true memory, and no longer a working configuration.
Either way: real certificate, real reverse proxy. The master password hash crosses that connection.
So you need a reverse proxy with a real certificate. If you are starting from nothing, our nginx proxy setup covers the base configuration; what follows is only the Vaultwarden-specific part.
server {
# `listen ... http2` rather than the newer `http2 on;` directive: that one
# only exists from nginx 1.25.1, and Ubuntu 24.04 ships 1.24, Debian 12 ships
# 1.22. On those, `http2 on;` fails nginx -t outright.
listen 443 ssl http2;
server_name vault.example.com;
ssl_certificate /etc/letsencrypt/live/vault.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vault.example.com/privkey.pem;
# Attachments and imports. The default 1m rejects a large vault import.
client_max_body_size 128M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Same upstream, same port. This block exists only to add the upgrade
# headers WebSocket needs. The prefix covers both /notifications/hub and
# /notifications/anonymous-hub -- `^~` so it wins over `location /`.
location ^~ /notifications/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Two things people get wrong here. client_max_body_size at nginx's 1MB default rejects a vault import from LastPass or 1Password and returns an error that explains nothing.
The second is why that block matches the whole /notifications/ prefix rather than /notifications/hub. There are two hubs. /notifications/anonymous-hub handles passwordless login approvals, and a config that adds upgrade headers to hub alone leaves anonymous-hub falling through to location / without them. Login-with-device then stops working while everything else looks healthy. I wrote this guide's nginx block the narrow way first and caught it only when checking the config against my own warning three sections later.
Verify Before You Trust It
docker compose up -d and the container comes up. Do not stop there.
$ curl -fsS http://127.0.0.1:8080/alive
"2026-09-11T14:22:07.481293Z"
/alive is the endpoint the image's own bundled healthcheck hits, on ROCKET_PORT, every 60 seconds. It returns a timestamp and nothing else.
Now read the first twenty lines of the log, which is the step almost nobody performs:
$ docker logs vaultwarden 2>&1 | head -20
[WARNING] The following environment variables are being overridden by the config.json file.
[WARNING] Please use the admin panel to make changes to them:
[WARNING] domain, signups_allowed
That warning comes out of src/config.rs before the logger is initialised, which is why it has no timestamp and scrolls past in the first half-second. It means /data/config.json exists — because somebody once opened the admin panel and clicked Save — and every key in that file beats your environment.
This is the single most expensive gotcha in Vaultwarden. You edit docker-compose.yml, restart, and nothing changes. Not silently failing. Working exactly as designed, on values you cannot see from the file you are editing. Either make the change in the admin panel, or delete the key from config.json and restart.
The Admin Token Preset Most Guides Get Backwards
The admin panel at /admin is a single-secret door. Do not put a plaintext token behind it; Vaultwarden will hash one for you. From src/main.rs at 1.37.2:
COMMAND:
hash [--preset {bitwarden|owasp}] Generate an Argon2id PHC ADMIN_TOKEN
backup Create a backup of the SQLite database
You can also send the USR1 signal to trigger a backup
PRESETS: m= t= p=
bitwarden (default) 64MiB, 3 Iterations, 4 Threads
owasp 19MiB, 2 Iterations, 1 Thread
Read the presets. bitwarden is the default and the stronger of the two: 65540 KiB, t=3, p=4, against owasp's 19456 KiB, t=2, p=1. Guides that tell you to run vaultwarden hash --preset owasp are telling you to take 30% of the memory cost and a quarter of the parallelism, whatever they intended. The owasp preset exists for hardware that cannot spare 64MiB. Everywhere else, run it bare:
$ docker run --rm -it vaultwarden/server:1.37.2 /vaultwarden hash
Generate an Argon2id PHC string using the 'bitwarden' preset:
Password:
Confirm Password:
Then, in a compose file, escape every $ as $$ and wrap the whole thing in single quotes. This is called out explicitly in the upstream template and it is the most common reason a correctly generated token silently fails to authenticate.
ADMIN_SESSION_LIFETIME defaults to 20 minutes, which is fine. DISABLE_ADMIN_TOKEN should stay false unless something else is genuinely doing the authentication — a zero-trust access layer in front of the route, not an assumption that nobody will guess the path.
Lock the Door. The Whitelist Unlocks It.
Standard advice is SIGNUPS_ALLOWED=false. Then, because you want your own domain to be able to register, you reach for the whitelist. Here is what the upstream template says about that setting:
## Controls if new users from a list of comma-separated domains can register
## even if SIGNUPS_ALLOWED is set to false
# SIGNUPS_DOMAINS_WHITELIST=example.com,example.net,example.org
Read the second line twice. The whitelist is not a filter narrowing open registration. It is an override that punches straight through SIGNUPS_ALLOWED=false. Set both and you have not restricted anything. You have re-enabled registration for anyone holding an address at those domains.
If signups are closed, close them. INVITATIONS_ALLOWED=true plus invites from the admin panel requires possession of a link, not a plausible email suffix.
Two Settings Named Almost Nothing Like What They Do
REQUIRE_DEVICE_EMAIL is not email verification at signup. It is a new-device login notification, and it is off by default:
## Require new device emails. When a user logs in an email is required to be sent.
## If sending the email fails the login attempt will fail!!
# REQUIRE_DEVICE_EMAIL=false
Those two exclamation marks are upstream, not mine. If your SMTP relay is down, nobody logs in. It couples vault availability to mail availability, permanently. Turn it on only once you are monitoring mail delivery.
The setting you wanted for signup verification is SIGNUPS_VERIFY, also false by default, and in the compose file above.
The one almost nobody enables is worth more than either:
# INCOMPLETE_2FA_TIME_LIMIT=3
An incomplete 2FA login is one where the correct master password was accepted and the second factor was never completed. That is the exact signature of a compromised master password meeting a second factor that held. Vaultwarden emails the user after three minutes. It is on by default; do not set the limit to 0.
A note on the schedule, because the format misleads: INCOMPLETE_2FA_SCHEDULE="30 * * * * *" is a six-field cron, so the leading 30 is the second of each minute. The job runs once a minute, not every thirty seconds. The template says so plainly.
Speaking of second factors — set one up. TOTP and WebAuthn both work with no server configuration at all, and WebAuthn means the passkey in your phone or your hardware key. Email 2FA needs SMTP, and it is the weakest of the three; it also happens to be the subsystem in the CVE below.
Mail, Because Half the Features Need It
Invites, verification, new-device alerts, the incomplete-2FA warning above, emergency access — all of it is email. Without SMTP those features are inert, and the admin panel will hand out invite links you have to copy manually.
## Mail specific settings, set SMTP_FROM and either SMTP_HOST or USE_SENDMAIL to enable the mail service.
## To make sure the email links are pointing to the correct host, set the DOMAIN variable.
## Note: if SMTP_USERNAME is specified, SMTP_PASSWORD is mandatory
# SMTP_HOST=smtp.domain.tld
# SMTP_FROM=vaultwarden@domain.tld
# SMTP_FROM_NAME=Vaultwarden
# SMTP_USERNAME=username
# SMTP_PASSWORD=password
# SMTP_TIMEOUT=15
The security setting is where people lose an evening:
## Choose the type of secure connection for SMTP. The default is "starttls".
## The available options are:
## - "starttls": The default port is 587.
## - "force_tls": The default port is 465.
## - "off": The default port is 25.
# SMTP_SECURITY=starttls
# SMTP_PORT=587
starttls on 587 and force_tls on 465 are not interchangeable. Point starttls at 465 and the connection hangs until SMTP_TIMEOUT expires, because the server is expecting TLS from the first byte and Vaultwarden is waiting to negotiate it. The error you get back says timeout, which tells you nothing about why.
Two settings to leave alone. SMTP_ACCEPT_INVALID_CERTS and SMTP_ACCEPT_INVALID_HOSTNAMES are both marked DANGEROUS upstream and both do exactly what they say to your transport security. And SMTP_DEBUG=false should stay false outside an active debugging session — the template's warning is that its output can contain usernames and passwords.
The admin panel has a test-email button. Use it before you turn on anything that couples logins to mail delivery.
Push Notifications Send Less Than You Think
Without push, the mobile apps only sync when you open them. With it, they sync when something changes. It is off by default and it needs credentials from Bitwarden:
## Enables push notifications (requires key and id from https://bitwarden.com/host)
# PUSH_ENABLED=false
# PUSH_INSTALLATION_ID=CHANGEME
# PUSH_INSTALLATION_KEY=CHANGEME
Register at bitwarden.com/host, take the installation ID and key, and — this is the part that breaks — match the relay URIs to the data region you chose there:
# Default Push Relay and Identity URIs
# PUSH_RELAY_URI=https://push.bitwarden.com
# PUSH_IDENTITY_URI=https://identity.bitwarden.com
# European Union Data Region Settings
# If you have selected "European Union" as your data region, use the following URIs instead.
# PUSH_RELAY_URI=https://api.bitwarden.eu
# PUSH_IDENTITY_URI=https://identity.bitwarden.eu
Pick European Union on the registration page, leave the relay URIs at their defaults, and your installation ID authenticates against the wrong region. Push silently never arrives.
Now the question a self-hoster should actually be asking: if my server talks to Bitwarden's relay, what leaves my box? You do not have to take anybody's word for it, mine included. I went and read src/api/push.rs at 1.37.2, and the payload for an item change is this:
send_to_push_relay(json!({
"userId": user_id,
"organizationId": null,
"deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device)
"identifier": device.uuid, // Should be the acting device id (aka uuid per device/app)
"type": ut as i32,
"payload": {
"id": cipher.uuid,
"userId": cipher.user_uuid,
"organizationId": null,
"collectionIds": null,
"revisionDate": format_date(&cipher.updated_at)
},
"clientType": null,
"installationId": null
}))
UUIDs and a revision date. No item name, no URL, no username, no ciphertext. The notification tells the client "something with this id changed at this time, come and sync"; the sync itself goes to your server.
That is not nothing, though. Bitwarden's relay learns your installation ID, your user and device UUIDs, and the timestamp of every vault change — a usable activity pattern for whoever holds those logs. Whether that trade is worth working mobile sync is your call, which is the whole reason to state what the trade actually is.
One real limitation, straight from the source:
pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device, conn: &DbConn) {
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
if cipher.organization_uuid.is_some() {
return;
}
Changes to organisation-owned items do not push. If your household shares a collection, edits to those entries will not wake anyone's phone — and the comment notes upstream Bitwarden behaves the same way. Personal items push; shared items wait for the next manual sync. Worth knowing before you spend an afternoon debugging a push setup that is working correctly.
Your Accounts Are on PBKDF2
This one gets missed because it is not a server setting. New accounts are created server-side with:
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
That is src/db/models/user.rs at 1.37.2. PBKDF2-SHA256 at 600,000 iterations: the Bitwarden-compatible default, and not weak. It is the memory-cheap option, and GPU attackers price memory-cheap KDFs very differently from memory-hard ones.
Argon2id has been supported on the clients since Vaultwarden 1.28.0, and it is a per-account choice made in the client rather than a server flag. Web vault, then Settings, Security, Keys, change KDF to Argon2id. Every device gets logged out afterwards. Do it on a day when you can re-authenticate all of them.
While you are in the source: PASSWORD_ITERATIONS=600000 in the template is the server-side hash of the password hash. Different value, different place, and changing one does not change the other.
Org Creation Is Open and Nothing Is Logged
Two defaults that are reasonable for a hosted product and wrong for your box.
## Controls which users can create new orgs.
## Blank or 'all' means all users can create orgs (this is the default):
# ORG_CREATION_USERS=
Every user on your instance can create organisations. Given where the 2026 advisories landed — see the table below, the answer is "the org permission model" — a family instance should set ORG_CREATION_USERS=none and let you create them.
## Controls whether event logging is enabled for organizations
## This setting applies to organizations.
## Disabled by default. Also check the EVENT_CLEANUP_SCHEDULE and EVENTS_DAYS_RETAIN settings.
# ORG_EVENTS_ENABLED=false
Org audit logging is off out of the box. If something happens inside an organisation, there is no record of it unless you turned this on beforehand. There is no retroactive audit log. Pair it with EVENTS_DAYS_RETAIN — unset, the cleanup job never starts and the events table grows without limit.
Your Rate Limits Are Rate Limiting a Header
Vaultwarden derives the client address for login and admin rate limits from a header. Which header, and whose word it takes for it, are two different settings:
## Client IP Header, used to identify the IP of the client, defaults to "X-Real-IP"
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
# IP_HEADER=X-Real-IP
## Which addresses the header above is accepted from, defaults to "local".
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
## proxy as IPs and CIDR ranges if it connects from a public address.
# IP_HEADER_TRUSTED_PROXIES=local
IP_HEADER_TRUSTED_PROXIES arrived in 1.37.0, in the pull request titled "Trusted proxy support, unauthenticated rate limit & other fixes." It cuts both ways.
Set it to all — which forum answers suggest whenever someone's fail2ban is banning the proxy's own address — and every attacker gets a fresh rate-limit bucket per forged X-Real-IP. The limiter still runs. It is metering an attacker-controlled string. If the proxy is on the same host or container network, local is correct; leave it alone. If it arrives from a public address, list those ranges. Never all.
Then the numbers. These are the upstream defaults:
# ADMIN_RATELIMIT_SECONDS=300
# ADMIN_RATELIMIT_MAX_BURST=3
# LOGIN_RATELIMIT_SECONDS=60
# LOGIN_RATELIMIT_MAX_BURST=10
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
A very widely copied config block sets ADMIN_RATELIMIT_SECONDS: "60" under a comment reading "Admin page rate limiting." The default is 300. Writing 60 there does not harden the admin panel. It makes admin login attempts five times cheaper than stock Vaultwarden allows. The login values in that same block are already the defaults, so those two lines do nothing at all. This is why the compose file above omits the section entirely.
UNAUTHENTICATED_RATELIMIT_* is also new in 1.37.0: a shared budget covering password hints, account recovery mail, and Send access, which were not metered as a group before.
The Oracle at /api/two-factor/send-email-login
The rate limiter has been bypassed outright, and recently.
CVE-2026-43914 (GHSA-c5rv-q295-7w4g, CVSS 3.1 base 7.3, AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L, CWE-307) covers Vaultwarden before 1.35.4. The check_limit_login guard was applied to _sso_login, _password_login and _api_key_login. It was not applied to send_email_login, the handler behind /api/two-factor/send-email-login. That endpoint answers differently depending on whether the submitted master password is correct, so it functions as an unmetered password oracle.
The part worth sitting with: per the CVE record, it worked against users who had no email 2FA configured, provided email 2FA was enabled on the server. The exposed population was not "people using email 2FA." It was everyone on an instance where email 2FA existed as an option.
The disclosure timeline matters more than the bug. The fix shipped in 1.35.4 on 23 February 2026. The advisory published 25 April. The CVE published 11 May. Seventy-seven days in which the patched release was public and the reason for it was not. Anyone waiting on a CVE feed spent eleven weeks on a known-broken auth path.
That is the operating model, not an anomaly:
| Release | Date | Security content |
|---|---|---|
| 1.35.3 | 10 Feb | Cross-collection item access |
| 1.35.4 | 23 Feb | Cipher access by UUID; two Manager-role collection flaws |
| 1.35.5 | 12 Apr | Unconfirmed owner can purge org vault; cross-org group binding; refresh tokens surviving stamp rotation |
| 1.36.0 | 3 May | SSO login CSRF; user/org enumeration; SSO existing-user binding; icon SSRF |
| 1.37.0 | 24 Jul | Eight entries, ten GHSA IDs: icon SSRF, cross-org cipher access, policy bypass on directory import, Send access-count bypass, unauthenticated WebSocket flood, cross-org secret sharing, org import authz, Manager-role enumeration |
Two patterns fall out. First, the org and collection permission model is where the bugs live. Cross-org, cross-collection, Manager-role scope: the boundary keeps turning out wider than intended. Run a single-user instance and you dodged most of 2026. Run organisations and your upgrade window is days, not quarters.
Second, releases carrying security fixes say so before CVEs exist — 1.37.0's notes state the advisories are private pending CVE assignment. Watch the releases page, not a vulnerability feed. Subscribe to the repo's releases and you get the notification the day it matters.
The icon SSRF entries carry their own lesson. Vaultwarden already ships the defence:
## Enabling this will cause the internal HTTP client to refuse to connect to any non-global IP address.
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
Default on. So the 2026 bypasses were not missing controls; one is catalogued upstream as a decimal/hex/octal IP encoding bypass. The control was correct. The parser in front of it was not, which is SSRF behaving exactly as it always does — the check and the thing being checked disagreeing about what a string means. If your instance has no business fetching favicons, ICON_SERVICE accepts bitwarden, duckduckgo or google to hand the fetch off entirely, or DISABLE_ICON_DOWNLOAD=true with ICON_CACHE_TTL=0 stops it dead.
If You Are on MySQL or MariaDB, Read This Before You Upgrade
Everything above assumes SQLite, which is the default and the right choice for a household. If you moved to MariaDB or MySQL at some point, there is an upgrade that will stop your server from starting, and it is worth knowing about before it happens rather than after.
Vaultwarden 1.36.0 added item archiving, and with it a migration called 2026-03-09-005927_add_archives. On some MariaDB installs it fails and takes the process down with it:
[2026-05-04 08:36:28.855][panic][ERROR] thread 'main' panicked at 'Error running migrations: QueryError(DieselMigrationName { name: "2026-03-09-005927_add_archives", version: MigrationVersion("20260309005927") }, DatabaseError(Unknown, "Can't create table `vaultwarden`.`archives` (errno: 150 \"Foreign key constraint is incorrectly formed\")"))': src/db/mod.rs:501
That is from upstream issue #7182. Migrations run at startup, so this is not a degraded feature. The server panics and does not come up.
Here is the migration, verbatim from migrations/mysql/2026-03-09-005927_add_archives/up.sql:
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL,
cipher_uuid CHAR(36) NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid),
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE,
FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE
);
Nothing wrong with it in isolation. The problem is what it does not say. Grep every MySQL migration in the repo and not one pins a character set or collation — every table inherits whatever the database default was on the day it was created. InnoDB requires a foreign key's column to match the referenced column's type and charset and collation exactly. So if users and ciphers were created years ago under one default and archives is created today under a different one, the FK is rejected.
I reproduced it. MariaDB 10.11.14 on Ubuntu 24.04: create users and ciphers in a database defaulting to utf8mb3_general_ci, move the database default to utf8mb4_general_ci the way a server upgrade or a helpful admin would, then run the real migration:
$ mysql vw < 2026-03-09-005927_add_archives/up.sql
ERROR 1005 (HY000) at line 3: Can't create table `vw`.`archives` (errno: 150 "Foreign key constraint is incorrectly formed")
Byte for byte the error from the issue. So check before you pull the new image:
SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'vaultwarden';
SELECT @@character_set_database, @@collation_database;
If the existing tables and the database default disagree, you are holding the failure.
The obvious fix does not work. Converting the old tables to match the new default is the first thing anyone tries, and InnoDB refuses:
$ mysql vw -e "ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
ERROR 1833 (HY000): Cannot change column 'uuid': used in a foreign key constraint 'vw/ciphers_ibfk_1' of table 'vw/ciphers'
You cannot retype a column another table's foreign key points at, and Vaultwarden's schema is full of them. Unpicking every constraint, converting, and putting them back is a long evening with your vault offline.
Move the database default instead. Point it back at whatever the existing tables already use, let the migration inherit that, and nothing has to be rewritten:
$ mysql -e "ALTER DATABASE vaultwarden DEFAULT CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;"
$ mysql vaultwarden < 2026-03-09-005927_add_archives/up.sql
$ mysql vaultwarden -e "SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA='vaultwarden';"
TABLE_NAME TABLE_COLLATION
archives utf8mb3_general_ci
ciphers utf8mb3_general_ci
users utf8mb3_general_ci
Both foreign keys land, the migration completes, and Vaultwarden starts. Substitute your own collation for utf8mb3_general_ci — the point is that it matches the tables you already have, not that it is that particular value. In practice you let Vaultwarden run the migration itself: set the database default, then start the new image.
Two caveats, stated plainly. I reproduced this on a synthetic two-table schema, not on a real years-old Vaultwarden database, so treat the mechanism as confirmed and the specifics of your install as unknown until you look. And take a pg_dump-equivalent — mysqldump — before any of it. This is the upgrade where you want a rollback.
Backups: The Command in Your Cron Job Does Not Exist
Here is the runtime stage of docker/Dockerfile.debian at 1.37.2:
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
# Create data folder and Install needed libraries
RUN mkdir /data && \
apt-get update && apt-get install -y \
--no-install-recommends \
ca-certificates \
curl \
libmariadb3 \
libpq5 \
openssl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ca-certificates, curl, libmariadb3, libpq5, openssl. There is no sqlite3 binary in that image. Every backup script built on docker exec vaultwarden sqlite3 /data/db.sqlite3 ".backup ..." fails on the official container, and it fails before the tarball is assembled — so what lands in your offsite bucket is a compressed folder of attachments with no database in it. Every night. Quietly. For as long as you let it.
Vaultwarden has had a first-party backup path for a while, and since April 2026 it uses VACUUM INTO rather than the legacy backup API. From src/db/mod.rs:
diesel::sql_query("VACUUM INTO ?")
.bind::<diesel::sql_types::Text, _>(&backup_file)
.execute(&mut conn)
.map(|_| ())
.map_res("VACUUM INTO failed")?;
It opens the database read-only, writes db_YYYYMMDD_HHMMSS.sqlite3 beside the original, and being a single SQLite statement it produces a consistent snapshot with the WAL already folded in. No -wal and -shm juggling. Two ways to trigger it:
# Signal the running process. No downtime, result goes to the log.
docker kill -s USR1 vaultwarden
# Or run the subcommand as a separate read-only process.
docker exec vaultwarden /vaultwarden backup
Then take the snapshot, attachments/, sends/, and rsa_key. That last one is not optional. It is a 2048-bit RSA key generated on first start and loaded into PRIVATE_RSA_KEY in src/auth.rs; every JWT the server issues is signed with it. Restore without it and Vaultwarden generates a fresh one, invalidating every session and token in existence. Skip icon_cache/. It rebuilds.
#!/bin/bash
# /opt/scripts/vaultwarden-backup.sh
set -euo pipefail
DATA=/opt/vaultwarden/data
DEST=/opt/backups/vaultwarden
KEYFILE=/opt/backup-key.txt
CONTAINER=vaultwarden
STAMP=$(date +%Y%m%d-%H%M)
WORK="${DEST}/.work-${STAMP}"
die() { echo "vaultwarden-backup: $*" >&2; exit 1; }
# A snapshot is a full plaintext copy of the vault database. Never leave one
# lying in the data directory -- clear any from a previous failed run first,
# so what we find after the signal can only be the one we just asked for.
find "${DATA}" -maxdepth 1 -name 'db_*.sqlite3' -delete
MARKER="${DATA}/.backup-marker"
: > "${MARKER}"
docker kill -s USR1 "${CONTAINER}" >/dev/null \
|| die "could not signal ${CONTAINER} -- is it running?"
# VACUUM INTO takes as long as it takes; on a large vault that is well over
# five seconds. Poll instead of guessing, and only accept a snapshot written
# after the marker.
SNAP=""
for _ in $(seq 1 60); do
SNAP=$(find "${DATA}" -maxdepth 1 -name 'db_*.sqlite3' -newer "${MARKER}" \
-printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2- || true)
[ -n "${SNAP}" ] && break
sleep 1
done
rm -f "${MARKER}"
[ -n "${SNAP}" ] || die "no new snapshot after 60s -- check: docker logs ${CONTAINER}"
# Confirm it is actually a SQLite database. The official image has no sqlite3
# binary, so check the file header directly.
[ "$(head -c 15 "${SNAP}")" = "SQLite format 3" ] \
|| die "snapshot ${SNAP} is not a SQLite database"
# rsa_key signs every JWT. A backup without it logs everyone out on restore,
# so treat a missing one as a failure rather than cp'ing past it.
[ -f "${DATA}/rsa_key" ] || die "rsa_key missing from ${DATA} -- refusing to back up"
mkdir -p "${WORK}"
mv "${SNAP}" "${WORK}/db.sqlite3"
cp "${DATA}/rsa_key" "${WORK}/rsa_key"
for d in attachments sends; do
[ -d "${DATA}/${d}" ] && cp -r "${DATA}/${d}" "${WORK}/${d}"
done
tar -czf "${WORK}.tar.gz" -C "${WORK}" .
rm -rf "${WORK}"
# Encrypt before it leaves the box. age-keygen -o /opt/backup-key.txt, once.
RECIPIENT=$(grep '^# public key:' "${KEYFILE}" | awk '{print $NF}')
[ -n "${RECIPIENT}" ] || die "no recipient found in ${KEYFILE}"
age -r "${RECIPIENT}" -o "${DEST}/${STAMP}.tar.gz.age" "${WORK}.tar.gz"
# Only drop the plaintext archive once the encrypted one exists and is not empty.
[ -s "${DEST}/${STAMP}.tar.gz.age" ] || die "encryption produced nothing"
rm -f "${WORK}.tar.gz"
# And sweep again, in case the server wrote another snapshot while we worked.
find "${DATA}" -maxdepth 1 -name 'db_*.sqlite3' -delete
find "${DEST}" -name '*.tar.gz.age' -mtime +30 -delete
echo "vaultwarden-backup: wrote ${DEST}/${STAMP}.tar.gz.age"
That script is original, not lifted from anywhere. I wrote it, then ran it against a simulated data directory with the container stubbed out, and every guard in it exists because the first version failed a test I expected it to pass.
The first version slept five seconds after the signal and then took the newest db_*.sqlite3 it could find. Three things wrong with that. VACUUM INTO on a multi-gigabyte vault takes considerably longer than five seconds, so the fixed sleep loses the race and the script aborts on a healthy server. Worse, if a snapshot from an earlier failed run is still sitting in the data directory, find happily returns that one — and you archive a week-old vault every night while the script reports success. That is the exact failure this guide keeps warning about, in this guide's own code.
Hence: sweep the directory first, drop a marker file, signal, then poll for a snapshot newer than the marker. I tested that against a twelve-second vacuum, which the five-second sleep loses outright. The freshness check is now structural rather than a guess about timing.
The third problem was cp -r ... 2>/dev/null || true, which swallows a missing rsa_key and produces an archive that logs everyone out on restore. The version above refuses to continue without it, and verifies the snapshot really is a database by reading its first fifteen bytes — SQLite format 3 — since the official image has no sqlite3 binary to ask.
A backup script that cannot fail loudly will report success for a year.
On PostgreSQL none of this applies. The backup subcommand returns The database type is not SQLite. Backups only works for SQLite databases, and the USR1 handler is never registered at all. Use pg_dump.
And the part everyone skips: restore it. Spin up a second container against a copy of the backup, log in, read one password. A backup you have never restored is a hypothesis.
Drop Root
The container runs as root. There is no USER directive in the Dockerfile, and ROCKET_PORT=80, so dropping privileges means moving the port as well:
user: "1000:1000"
environment:
ROCKET_PORT: "8080"
Chown the data directory to match and repoint the proxy. The bundled healthcheck reads ROCKET_PORT from the environment, so it follows along without changes.
When It Breaks
Config changes have no effect. config.json is winning. Check the startup warning above, then either change it in the admin panel or delete the key from /data/config.json and restart. The environment variable takes over once the key is gone.
Empty vault after changing the database. Before 1.37.0, a DATABASE_URL matching no known scheme fell through to SQLite and created a fresh, empty database — a typo in a Postgres URL produced a working, empty server. 1.37.0 changed that; the source comment is explicit that it "prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database." If this already happened on an older version, your real data is still where it was. Fix the URL. Do not let anything write to the new file.
Clients connect but never sync in real time. The /notifications/hub proxy block, or its missing upgrade headers. Vaultwarden logs WebSocket are disabled, realtime sync functionality will not work! at startup if ENABLE_WEBSOCKET is off, which is the other half of the diagnosis.
Login-with-device does nothing. /notifications/anonymous-hub is not proxied with upgrade headers. Worth knowing: the source caps it at MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25, added as the fix for the unauthenticated WebSocket flooding advisory in 1.37.0. Behind heavy NAT you can legitimately brush that ceiling.
Vault import fails on a big file. client_max_body_size. nginx defaults to 1MB.
fail2ban bans nothing. Three causes, and the third is one I got wrong in a draft of this piece. First, the line you are matching, at the default LOG_LEVEL=info with EXTENDED_LOGGING=true and the [{timestamp}][{target}][{level}] {message} layout from src/main.rs:
[2026-09-05 03:42:15.214][vaultwarden::api::identity][ERROR] Username or password is incorrect. Try again. IP: 203.0.113.42. Username: user@example.com.
Second, an INI section cannot have two failregex = keys; the later silently replaces the earlier, so multiple patterns must be continuation lines.
Third, the three lines you want are written at three different call sites and do not share punctuation:
// src/api/identity.rs:378
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip))
// src/api/admin.rs:221
error!("Invalid admin token. IP: {}", ip.ip);
// src/api/admin.rs:889
error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
The admin token line has no period after the address. The other two do. A pattern ending IP: <ADDR>\. matches two of the three and silently drops the one guarding your admin panel.
# /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^\[[^\]]+\]\[[^\]]+\]\[ERROR\] Username or password is incorrect\. Try again\. IP: <ADDR>\.
^\[[^\]]+\]\[[^\]]+\]\[ERROR\] Invalid admin token\. IP: <ADDR>
^\[[^\]]+\]\[[^\]]+\]\[ERROR\] Invalid or expired admin JWT\. IP: <ADDR>\.
datepattern = ^\[%%Y-%%m-%%d %%H:%%M:%%S
ignoreregex =
Do not take my word for it. fail2ban-regex reports matches per pattern, and a pattern showing zero against a log full of failed logins is the whole bug, visible in one command:
$ fail2ban-regex /opt/vaultwarden/data/vaultwarden.log \
/etc/fail2ban/filter.d/vaultwarden.conf
Check IP_HEADER_TRUSTED_PROXIES at the same time. If Vaultwarden is logging your proxy's address instead of the client's, fail2ban will faithfully ban your proxy.
Push notifications never arrive. Almost always a region mismatch: you chose European Union at bitwarden.com/host but left PUSH_RELAY_URI and PUSH_IDENTITY_URI at their bitwarden.com defaults. The installation ID then authenticates against the wrong region and nothing is reported back to you. Check the two URIs against the region you registered in. If personal items push and shared ones do not, that is not a fault — org-owned ciphers return early in push_cipher_update, by design.
SMTP times out with no useful error. SMTP_SECURITY and SMTP_PORT disagree. starttls wants 587, force_tls wants 465. Pointing starttls at 465 hangs until SMTP_TIMEOUT elapses, because the server wants TLS immediately and Vaultwarden is waiting to negotiate. Swap to force_tls and retry the admin panel's test email before changing anything else.
Logs stop after logrotate. Vaultwarden reopens LOG_FILE on SIGHUP — fern::log_reopen1(path, [SIGHUP]) in src/main.rs. Without a postrotate that signals it, the process keeps writing to the rotated inode and the live log stays empty.
postrotate
docker kill -s HUP vaultwarden
endscript
Nobody can log in and mail is down. REQUIRE_DEVICE_EMAIL=true. That is the coupling described earlier, arriving.
Mobile or the browser extension stopped working after a client update. Compare the server version against the client version before debugging anything else. 1.37.0 is required for clients at 2026.7.0+, 1.37.2 for 2026.8.0+.
The 2026.8.0 extension release is the current example, and it has a second half. Users across Chrome, Firefox, Edge and Vivaldi reported the extension failing to log in or showing an empty vault, with the server logging 2FA token not provided. Upgrading to 1.37.2 is necessary but on its own was not sufficient for everyone: the extension had cached state that survived the upgrade, and people got working again only after removing and reinstalling it. If the server is current and one client still misbehaves while others are fine, suspect the client's cache before you suspect your config.
Invites stopped working on 1.37.0. That was a real regression, fixed in 1.37.1 on 29 July 2026. If you applied a local workaround for it at the time, upstream's release notes ask you to revert that workaround rather than carry it forward. 1.37.1 also fixed an OpenSSL build problem in the Alpine image.
One thing worth wiring up while you are here: /alive is a clean scrape target, and a password server going quiet is exactly the failure you want a page for rather than a discovery. If you already run Prometheus and Grafana, a blackbox probe against that endpoint is about four lines of config.
What This Doesn't Cover
SSO. Vaultwarden's OIDC support absorbed a large share of 2026's advisories — CSRF in the authorisation flow, existing-user binding, discovery-endpoint enumeration — and it deserves its own Saturday rather than three paragraphs here. Nothing above addresses it.
Also out of scope: the S3 storage backend added in 1.37.0, PostgreSQL tuning, migrating between database backends, Duo, LDAP sync, and network placement. The MySQL section above is an upgrade warning, not a guide to running on it. Whether this should be on the public internet at all is a real argument with a defensible answer on both sides, and it is a proxy and access-layer question rather than a Vaultwarden one.
I have not benchmarked the Argon2id migration against PBKDF2 on client hardware. The unlock-time cost is real and varies by device. Measure it on your oldest phone before you migrate a family onto it.
The Actual Point
Vaultwarden is good software. Small, fast, seriously maintained, and the advisory cadence above is evidence of that rather than against it — those bugs were found, fixed, and disclosed with affected versions named and scores attached. Several third-party audits have been done and the project keeps a wiki page listing the public ones. Compare all of that to a vendor whose status page says no critical vulnerabilities found.
It also tells you what it is not. The project's own wiki states it targets individuals, families and smaller organizations, and that features aimed mainly at large organisations — single sign-on, directory syncing — are not a development priority. That is a useful thing to read before you deploy it as a company password manager and then file issues about SSO.
But the defaults are compatibility defaults, not hardening defaults. They exist so a Bitwarden client connects on the first try, on a Pi, behind whatever somebody has already built. Every setting in this piece has a reason to sit where it sits. None of those reasons is your threat model.
So read the template. Not this guide, not the one you found on Reddit. The 694-line .env.template that ships in the repo, at the tag you are actually running. It takes twenty minutes and it is the only document that cannot be out of date.
$ tail -f /forums
Discussion
Comments live on the community forums — sign in there to reply.