Vaultwarden on a VPS: self-host passwords
Self-host a Bitwarden-compatible password manager with Vaultwarden and Docker on a VPS: HTTPS-first, admin token, Fail2ban, tested backups.
What you are building
A password manager you fully own: Vaultwarden running in one small container behind a reverse proxy that terminates HTTPS, with the official Bitwarden apps on your phone, laptop and browser pointed at it. Vaultwarden re-implements the Bitwarden server API in Rust and speaks the same protocol as bitwarden.com, so every official client works against it unchanged — but it fits in about 100 MB of RAM instead of the multi-container official stack.
The install itself is a dozen lines of Compose. The three things that actually matter — and that break — are these: TLS must exist before you ever load the web vault, public signups must be closed the moment your own account exists, and the data volume must be backed up and test-restored, because that one directory holds every password you own.
Prerequisites and the honest gotchas
- A VPS with Docker Engine and the Compose plugin, on a fresh Ubuntu 24.04 KVM box with root or sudo. 512 MB of RAM is genuinely enough; 1 GB is comfortable. This is one of the lightest things you can run — it sits near the top of the shortlist of services worth self-hosting.
- A domain with an A record (and AAAA if you have IPv6) pointing
vault.example.comat the VPS. The TLS certificate is issued for this exact name, so DNS must resolve before you start. - Ports 80 and 443 open to the internet, terminated by your reverse proxy — never by Vaultwarden directly. Port 80 is used only for the ACME certificate challenge and an HTTP-to-HTTPS redirect.
- The biggest gotcha up front: the Bitwarden clients refuse to talk to a server that is not HTTPS. There is no "test it over http first" — that path does not work, for a concrete reason covered next.
Why Vaultwarden, not the official Bitwarden stack
Same clients, a fraction of the weight. The official self-hosted Bitwarden ships as a bundle of containers (MSSQL, Nginx, Identity, Api, Admin and more) and wants roughly 2 GB of RAM. Vaultwarden is a single binary that stores everything in a SQLite database by default and idles at a few tens of megabytes. For one person, a family or a small team it is the obvious choice, and because it implements the Bitwarden API faithfully your data stays portable between it and bitwarden.com.
What you give up is most of the enterprise surface: no SCIM provisioning (though experimental OpenID Connect SSO landed in 1.35.0), and you are the operator, so patching, HTTPS and backups are your job. This guide is those three jobs.
Why HTTPS is not optional
The Bitwarden web vault and browser extensions derive your encryption keys in the browser using the Web Crypto API (window.crypto.subtle). Browsers only expose crypto.subtle in a secure context — HTTPS, or the special case of http://localhost. Over plain http://vault.example.com it is undefined, so the instant the app derives a key it throws, and the console shows:
Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'importKey')
The page hangs or shows a generic crypto error, and nothing logs in. The desktop, mobile and browser clients run their own check against a self-hosted URL, and against an http (or unreachable) endpoint they refuse with:
This is not a recognized Bitwarden server. You may need to check with your provider or update your server.
Both have the same cause: no valid HTTPS. So we stand up TLS first and never open the vault over http, not even once for a quick look.
Step 1 — DNS and the reverse proxy (TLS first)
Point the record at your VPS and confirm it resolves to the right address:
dig +short vault.example.com
The line it prints must be your VPS IP. If it is blank or wrong, fix DNS and wait out the TTL — certificate issuance fails against a name that does not resolve.
For the HTTPS front end this guide uses Traefik, which issues and renews Let's Encrypt certificates automatically and slots straight into Compose. If you do not already run it, follow the Traefik reverse proxy and automatic TLS setup first; it creates an external Docker network (proxy below) and an ACME resolver (letsencrypt) that the Vaultwarden service attaches to. Plain nginx with a hand-issued certificate works identically from Vaultwarden's side.
Prefer nginx and Certbot instead of Traefik? Put Vaultwarden on 127.0.0.1:8080 (add ports: ["127.0.0.1:8080:80"] to the service and drop the Traefik labels), then issue a certificate and proxy to it. The certificate half is covered in issuing Let's Encrypt certificates with Certbot and nginx. The critical extra is the WebSocket upgrade on the notifications path:
server {
listen 443 ssl;
server_name vault.example.com;
client_max_body_size 525M;
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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Note the X-Real-IP line — it is what lets Fail2ban later see the real attacker instead of 127.0.0.1. Everything else in this guide is identical whether Traefik or nginx sits in front.
Step 2 — the Compose file
Create the project directory first. This guide uses /opt/vaultwarden, which makes the Compose project name — and therefore the data volume, vaultwarden_vw-data — predictable; the Fail2ban and backup steps below depend on that exact name.
sudo mkdir -p /opt/vaultwarden
cd /opt/vaultwarden
Create a .env for the admin secret and the Compose file in that directory.
# .env
ADMIN_TOKEN=paste-a-strong-token-here
Generate that token with openssl rand -base64 48 and paste it in. (A stronger hashed form is covered next; a long random string is fine to start.)
# docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "true" # closed in Step 4, keep true just to register
ADMIN_TOKEN: "${ADMIN_TOKEN}"
IP_HEADER: "X-Forwarded-For" # X-Real-IP if your proxy sends that instead
LOG_FILE: "/data/vaultwarden.log"
LOG_LEVEL: "warn"
volumes:
- vw-data:/data
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.vw.rule=Host(`vault.example.com`)"
- "traefik.http.routers.vw.entrypoints=websecure"
- "traefik.http.routers.vw.tls.certresolver=letsencrypt"
- "traefik.http.services.vw.loadbalancer.server.port=80"
volumes:
vw-data:
networks:
proxy:
external: true
Two things about this file carry the whole design. There is no ports: mapping, so Vaultwarden is reachable only through Traefik and its TLS — publishing its port on the host is how people serve the vault over http by accident. And DOMAIN must be the full public HTTPS URL: it is baked into attachment links, WebAuthn 2FA and the notifications endpoint, so a wrong or http value breaks those even when the site loads.
Bring it up and watch the log:
docker compose up -d
docker compose logs -f vaultwarden
A correct start ends with a line like Rocket has launched from http://0.0.0.0:80. Give Traefik a few seconds to fetch the certificate, then load https://vault.example.com — you should get the Bitwarden web vault with a valid padlock and no certificate warning.
Step 3 — a strong ADMIN_TOKEN, and the $$ trap
ADMIN_TOKEN guards /admin, the panel that can read every user and setting on your instance, so treat it like a root password. Two forms work.
The simple form is the random string you already generated with openssl rand -base64 48. Because base64 never contains a $, it drops straight into .env with no escaping.
The hardened form is an Argon2 PHC hash, so the plaintext token is never stored on disk. Generate one against the same image:
docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp
It prompts twice and prints a string starting $argon2id$v=19$.... Here is the trap that costs people an hour: Docker Compose treats $ as variable interpolation, so you must double every $ to $$ when you paste the hash into the Compose file. Put it directly under environment:, not through .env, and do not wrap it in quotes:
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$c29tZXNhbHQ$$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
If you leave the single $ signs, Compose warns The "argon2id" variable is not set and blanks the token, and /admin then rejects your correct password. Run docker compose up -d, and keep the plaintext you typed at the prompt in your own password store.
Step 4 — register your account, then lock the door
With SIGNUPS_ALLOWED: "true", open https://vault.example.com, click Create account, and register with your email and a strong master password. This master password is never recoverable — there is no reset — so store it somewhere durable first.
Now close the door. Edit the Compose file so signups are off:
SIGNUPS_ALLOWED: "false"
Re-apply with docker compose up -d. This is not hardening you can defer. Left open, anyone who finds the URL — and crawlers do — can create an account on your server. They cannot read your vault, but they consume resources and turn your private instance into an open service. The tell that you left it on: /admin lists accounts you never created.
To add family or teammates later without reopening public signups, use the Invite User button in /admin; that path needs SMTP configured so the invitee receives their link.
Step 5 — reaching /admin
Browse to https://vault.example.com/admin and enter the plaintext admin token (the random string, or the password you hashed — not the hash itself). Inside you can list users, tune settings, send a test email and take a database snapshot.
If the page returns 404 Not Found, ADMIN_TOKEN is empty or unset, which disables the panel entirely — itself a valid choice if you never need it. If it loads but rejects your token, see the $$ escaping trap in the failure list below. Forgotten the token? There is no recovery prompt; edit .env or the Compose file, set a fresh one, and docker compose up -d.
Step 6 — connect the Bitwarden clients
Every official client can point at a self-hosted server, so install the Bitwarden desktop, mobile or browser client from the normal stores — you do not need a special Vaultwarden build.
Before logging in, open the settings gear on the login screen (labelled Self-hosted or Region → Self-hosted), set Server URL to https://vault.example.com, and save. Then log in with the email and master password you registered; the client should connect immediately and offer to fill and save credentials.
If a client shows This is not a recognized Bitwarden server. You may need to check with your provider or update your server., the URL is wrong, uses http, or the certificate is untrusted — re-check that https://vault.example.com loads cleanly in a browser first. Slow updates on other devices are WebSocket push, covered below.
Step 7 — a Fail2ban jail for the login endpoint
Vaultwarden logs every failed login to the file set by LOG_FILE — exactly what a brute-force guard needs. If you are not already running Fail2ban, the install and basics are in the Fail2ban SSH hardening guide; here we add one jail for the vault.
First find where the named volume lives on the host, so Fail2ban can read the log:
docker volume inspect vaultwarden_vw-data --format '{{ .Mountpoint }}'
That prints something like /var/lib/docker/volumes/vaultwarden_vw-data/_data; the log is vaultwarden.log inside it. Create the filter:
# /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =
And the jail:
# /etc/fail2ban/jail.d/vaultwarden.local
[vaultwarden]
enabled = true
filter = vaultwarden
logpath = /var/lib/docker/volumes/vaultwarden_vw-data/_data/vaultwarden.log
banaction = iptables-allports
chain = DOCKER-USER
maxretry = 5
findtime = 600
bantime = 3600
Reload with sudo systemctl restart fail2ban and confirm with sudo fail2ban-client status vaultwarden.
Three Docker details decide whether this protects anything. First, if the log shows IP: 127.0.0.1 or your proxy's address on every failed attempt, Vaultwarden is banning the proxy — set IP_HEADER to the header your proxy really sends (X-Forwarded-For for Traefik, X-Real-IP for the nginx block above, CF-Connecting-IP behind Cloudflare). Second, the right iptables chain depends on your proxy: with Traefik running as a container with published ports, traffic traverses Docker's FORWARD path, so the ban must sit in DOCKER-USER as above; but if you chose the host-nginx option from Step 1, connections terminate at nginx on the host's INPUT chain and a DOCKER-USER ban never sees them — in that case delete the chain = DOCKER-USER line so Fail2ban uses the default INPUT chain. Third, use banaction = iptables-allports rather than the port-based default — this jail defines no port, and an all-ports ban in DOCKER-USER cleanly blocks the offender from every published service on the box.
Step 8 — back up the vault, then actually restore it
The vw-data volume is your password manager. It holds db.sqlite3 (every entry), the attachments/ and sends/ directories, the rsa_key.* files that sign login sessions, and config.json from the admin panel. A backup that skips any of these fails when you need it.
Copying db.sqlite3 while Vaultwarden is writing can capture a half-written, corrupt file, so take a cold snapshot — the downtime is a few seconds:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F)
DEST=/root/vw-backups
VOL=$(docker volume inspect vaultwarden_vw-data --format '{{ .Mountpoint }}')
mkdir -p "$DEST"
docker compose -f /opt/vaultwarden/docker-compose.yml stop vaultwarden
tar czf "$DEST/vw-$STAMP.tgz" -C "$VOL" .
docker compose -f /opt/vaultwarden/docker-compose.yml start vaultwarden
Run it from cron nightly and copy the .tgz off the box — a backup living only on the server you are protecting is not a backup. The admin panel's Backup Database button is a handy hot snapshot of the SQLite file alone, but it omits attachments and keys.
Now the ritual that separates a real backup from a hopeful one — restore it once and prove it works:
mkdir -p /tmp/vw-restore
tar xzf /root/vw-backups/vw-2026-07-15.tgz -C /tmp/vw-restore
docker run --rm -p 127.0.0.1:8888:80 -v /tmp/vw-restore:/data vaultwarden/server
From your laptop, tunnel to it with ssh -L 8888:127.0.0.1:8888 you@your-vps and open http://localhost:8888. Because localhost is a secure context, crypto.subtle is available and the vault decrypts over plain http here — the one place that is allowed. Log in with your master password and confirm your entries are present: if they are, your database, RSA keys and master password all round-trip, and you can rebuild on a fresh VPS in minutes. Stop the container with Ctrl-C and delete /tmp/vw-restore.
Failure modes, with the strings you will see
Cannot read properties of undefined (reading 'importKey') in the browser console. The vault was loaded over http, so crypto.subtle is undefined; reach it only via https:// and add the HTTP-to-HTTPS redirect at the proxy.
This is not a recognized Bitwarden server... in a client. The Server URL is http, mistyped, or the certificate is untrusted; confirm https://vault.example.com shows a valid padlock, then re-enter it in the client's self-hosted settings.
/admin rejects the correct password. The Argon2 hash lost its escaping — every $ must be $$ in Compose — or you entered the hash instead of the plaintext it represents.
Slow cross-device sync; console shows WebSocket connection to 'wss://vault.example.com/notifications/hub' failed. The proxy is not forwarding the Upgrade/Connection headers; Traefik does this automatically, nginx needs the two upgrade lines from Step 1. The vault still works, just syncing on open. The old dedicated port 3012 is gone since v1.31.0, so no separate WebSocket route is needed.
Fail2ban reports a ban but the attacker keeps connecting. It is banning 127.0.0.1 because IP_HEADER is wrong, or the ban sits in the wrong iptables chain — set chain = DOCKER-USER and banaction = iptables-allports.
Upgrades
Pull the new image and recreate; the named volume and all your data persist:
docker compose pull
docker compose up -d
Vaultwarden ships frequent releases. Watch the project's release notes rather than pinning a patch version, since some releases carry migration notes. Take a fresh backup before any major bump; you can roll back by restoring the tarball into a new volume.
FAQ
Is Vaultwarden the same as Bitwarden?
It is a compatible, independent server, not the official one. Vaultwarden re-implements the Bitwarden server API in Rust, so the official desktop, mobile, browser and CLI clients all work against it, at a fraction of the resources of the official stack. The vault format is the same, so you can migrate either direction by exporting and importing.
Do I really need HTTPS, or can I run it over http on my LAN?
You need HTTPS for anything but a localhost test. The Bitwarden web vault and extensions use the browser's Web Crypto API, which is only available in a secure context, so over plain http the client throws Cannot read properties of undefined and never logs in. The only http address that works is http://localhost, which is why the restore test in Step 8 uses an SSH tunnel.
How do I stop strangers from registering on my server?
Set SIGNUPS_ALLOWED: "false" in the Compose file and run docker compose up -d, immediately after creating your own account. From then on, add new people through the Invite User button in /admin, which needs SMTP configured so they receive the invitation link. Check the admin user list occasionally to confirm no unexpected accounts appeared.
How do I back up my Vaultwarden vault?
Stop the container briefly and archive the whole vw-data volume — db.sqlite3, attachments/, sends/, config.json and the rsa_key.* files — then copy the archive off the server, ideally on a nightly cron. Copying the live SQLite file while the server runs risks a corrupt snapshot, so take it cold. Most importantly, restore it once to a throwaway container and log in, so you know the backup is real before you depend on it.
Is it actually safe to self-host my passwords?
Yes, when you do the three things this guide covers: real HTTPS, closed signups plus a strong admin token, and tested backups. Your vault is encrypted client-side with your master password, so even the server never sees your passwords in the clear — a stolen db.sqlite3 is useless without it. The trade is that patching and backups are now your responsibility, which is why Fail2ban and the restore ritual are not optional here.