Self-host Bitwarden with Vaultwarden on a VPS
Vaultwarden is a tiny Rust reimplementation of the Bitwarden server. Same official clients, same UX, no monthly bill. Setup with Docker, Caddy, and proper backups.

Bitwarden is the best open-source password manager. The official clients are great. Their hosted plan is fine. The catch is that the server is heavy (Microsoft SQL, .NET) for what amounts to "store an encrypted blob and serve it back." Vaultwarden is a Rust reimplementation that fits in 50 MB of RAM, speaks the same protocol as the official clients, and runs happily on the smallest VPS we sell.
Sizing
Our smallest plan is enough. 1 vCore, 2 GB RAM, 30 GB SSD. Forever.
Stack
- Docker + Compose
- Caddy reverse proxy out front (see the Caddy guide)
- SQLite by default (it's a tiny database for personal/family use)
- Postgres or MySQL if you have >50 active users; otherwise don't bother
DNS first
Point vault.example.com at your VPS via A and AAAA records. Wait for it to resolve before continuing.
Install Docker
Ubuntu 24.04's own repos carry everything you need, no third-party apt source required:
sudo apt update
sudo apt install -y docker.io docker-compose-v2
That gives you the docker compose plugin the rest of this guide uses. The remaining commands assume you're root; if you're on a regular user, prefix them with sudo, Docker commands included, since only root and the docker group can talk to the Docker socket.
docker-compose.yml
Give the stack a fixed home first. Compose resolves the ./data volume relative to the compose file's directory, and the backup script later in this guide assumes this exact path:
mkdir -p /srv/vaultwarden && cd /srv/vaultwarden
One more prerequisite: the invite-only flow further down depends on Vaultwarden sending email, so you need a working SMTP account, either your existing mail host or a transactional provider like Mailgun or Postmark. The SMTP_* values below are placeholders; swap in your real server, sender address, and credentials, or the invitation email in the signup section will never arrive.
Create docker-compose.yml there:
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
ADMIN_TOKEN: "GENERATE_A_LONG_RANDOM_STRING"
WEBSOCKET_ENABLED: "true"
LOG_FILE: "/data/vaultwarden.log"
SMTP_HOST: "smtp.example.com"
SMTP_FROM: "vault@example.com"
SMTP_PORT: "587"
SMTP_SECURITY: "starttls"
SMTP_USERNAME: "vault@example.com"
SMTP_PASSWORD: "smtp-password-here"
volumes:
- ./data:/data
ports:
- "127.0.0.1:8080:80"
LOG_FILE makes Vaultwarden write its log into the data volume, so it lands at /srv/vaultwarden/data/vaultwarden.log on the host. The fail2ban setup in the Hardening section reads it from there.
Generate the admin token:
openssl rand -base64 48
Replace GENERATE_A_LONG_RANDOM_STRING in docker-compose.yml with the output before bringing the stack up. A word of caution before you paste it: the admin token grants full control of the server and every vault on it, and here it sits in plaintext, so anyone who can read the compose file owns your Vaultwarden. Prefer storing an Argon2 hash of the token instead, generated with docker run --rm -it vaultwarden/server /vaultwarden hash (double every $ in the resulting hash as $$ so Compose doesn't treat it as a variable). At minimum, chmod 600 docker-compose.yml.
Bring it up:
docker compose up -d
docker compose logs -f
Caddyfile
Assuming Caddy is already installed and running per the Caddy guide, append this block to /etc/caddy/Caddyfile:
vault.example.com {
reverse_proxy localhost:8080 {
header_up X-Real-IP {remote_host}
}
}
The header_up line passes the real client IP through to Vaultwarden. Without it, every request logs as 127.0.0.1 and the fail2ban jail in the Hardening section has nothing useful to ban.
sudo systemctl reload caddy
Lock down signups, invite yourself
SIGNUPS_ALLOWED=false means no random strangers can register. Open https://vault.example.com/admin and paste the admin token (the raw token, even if the compose file stores the hash). Before inviting anyone, scroll to the SMTP Email Settings section and hit "Send test email". With signups disabled, the invitation email is your only way in, so if the test never lands in your inbox, fix the SMTP_* values in the compose file and run docker compose up -d again before going further. Once the test arrives, send yourself an invitation, accept it from your address, and you're done.
Configure the clients
Bitwarden's official clients (browser, mobile, desktop) all support custom server URLs. On login, click "Self-hosted" and enter https://vault.example.com. Everything else works identically to bitwarden.com.
Hardening
- Disable admin panel in production: once you've configured what you need, comment out
ADMIN_TOKENand restart. Re-enable for changes. - Two-factor on every account: Vaultwarden supports TOTP, FIDO2 (security keys), Duo. Use it.
- No public signups: keep
SIGNUPS_ALLOWED=falsepermanently. Use invitations.
Fail2ban for failed logins
Vaultwarden logs every failed vault login and every bad admin token to the log file we configured in the compose file. Install fail2ban (our fail2ban guide covers it in depth):
sudo apt update
sudo apt install -y fail2ban
Create the filter at /etc/fail2ban/filter.d/vaultwarden.conf:
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
^.*Invalid admin token\. IP: <ADDR>.*$
ignoreregex =
The first pattern catches vault login brute-force, the second catches guesses at /admin. Then the jail, at /etc/fail2ban/jail.d/vaultwarden.local:
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /srv/vaultwarden/data/vaultwarden.log
maxretry = 5
bantime = 4h
findtime = 15m
One thing before you switch it on: this jail can't tell you apart from an attacker. Fat-finger your master password five times and you're banned for four hours like anyone else. If you have a stable home or office IP, add it to ignoreip in the jail before restarting.
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden
The second command should list the jail with zero banned IPs. Trigger a deliberately failed login from another network and watch the counter move if you want proof it works.
Backups
The whole state lives in /srv/vaultwarden/data. The script below uses the sqlite3 CLI on the host to take a consistent snapshot of the database, and Ubuntu 24.04 doesn't ship it by default:
sudo apt install -y sqlite3
Save the script below as /usr/local/bin/vw-backup.sh. One thing to know before you run it: the two find lines at the end permanently delete backups older than 14 days from $DEST, so keep that directory dedicated to Vaultwarden backups and never point it at anything shared.
#!/bin/bash
DEST=/srv/vw-backups
DATE=$(date +%F)
mkdir -p "$DEST"
# Use sqlite backup command for a consistent dump
sqlite3 /srv/vaultwarden/data/db.sqlite3 ".backup '$DEST/db-$DATE.sqlite3'"
tar czf "$DEST/full-$DATE.tar.gz" -C /srv/vaultwarden data
find "$DEST" -type f -name 'db-*.sqlite3' -mtime +14 -delete
find "$DEST" -type f -name 'full-*.tar.gz' -mtime +14 -delete
Make it executable and open root's crontab:
sudo chmod +x /usr/local/bin/vw-backup.sh
sudo crontab -e
Add this line to run it nightly at 03:00:
0 3 * * * /usr/local/bin/vw-backup.sh
Then get the backups off the VPS. They are your password vault: protect them with the same care you'd protect the live data. restic encrypts everything client-side before upload, which makes it the easy safe choice for a B2/S3 bucket:
sudo apt install -y restic
export AWS_ACCESS_KEY_ID=your-key-id
export AWS_SECRET_ACCESS_KEY=your-secret-key
restic -r s3:s3.amazonaws.com/your-bucket init
restic -r s3:s3.amazonaws.com/your-bucket backup /srv/vw-backups
init asks you to set a repository password. That password is what encrypts the backups, so store it somewhere other than the vault it protects.
Migrating from bitwarden.com
Export your vault from the bitwarden.com web app (Tools, then Export vault). If you use the encrypted JSON format, pick "Password protected" in the export dialog, not the default "Account restricted": an account-restricted export can only be decrypted by the account that created it, so it will not import into a fresh Vaultwarden account, and it becomes permanently unreadable once the old account is gone. Plain unencrypted JSON also works if you keep it off shared machines and delete it as soon as the import is done.
Import into Vaultwarden via the web UI (Tools, then Import data), then confirm the item counts match your cloud vault before you change anything else. Switch your client server URL.
Before you delete the cloud account, know what an export leaves behind: file attachments, Sends, and anything in the trash never make it into the export file, and account deletion is irreversible. Download attachments by hand and re-upload them to Vaultwarden first. Only then delete the cloud account.
That's a self-hosted password manager that costs less than two months of Bitwarden Premium and runs forever on the smallest VPS we sell.
Continue reading