Deploying a Self-Hosted Password Manager with Vaultwarden and a Reverse Proxy
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
Handing your passwords to a third-party SaaS provider has always made me a little uneasy. Vaultwarden — a lightweight, open-source Bitwarden-compatible server written in Rust — gives you full control over your credential vault without sacrificing the polished Bitwarden clients you already know. In this tutorial I'll walk you through a production-ready deployment using Docker Compose and Caddy as the reverse proxy, including automatic HTTPS, persistent storage, and a few security tweaks that most guides skip.
Why Vaultwarden Instead of the Official Bitwarden Server?
The official Bitwarden server is a multi-container beast that wants gigabytes of RAM. Vaultwarden is a single container that idles comfortably under 20 MB of memory. I've been running it on a $6/month DigitalOcean Droplet for over two years without issues. It implements virtually the entire Bitwarden API — including organisations, TOTP, emergency access, and the Send feature — so every official Bitwarden client (browser extension, Android, iOS, desktop) connects to it without modification.
The one thing you need is HTTPS. Bitwarden clients refuse to connect to plain HTTP, which is actually a security feature you want. That's where the reverse proxy comes in.
Prerequisites
- A VPS or home server running Ubuntu 22.04 / Debian 12 (or similar)
- Docker Engine 24+ and Docker Compose v2 installed
- A domain name with an A record pointing at your server's IP
- Ports 80 and 443 open in your firewall (
ufw allow 80 && ufw allow 443)
If you need a reliable place to run this, create a DigitalOcean account — their $6 Basic Droplet (1 vCPU, 1 GB RAM) is more than enough for a personal or family vault.
Project Directory Layout
I keep everything under /opt/vaultwarden. Create the directories now:
sudo mkdir -p /opt/vaultwarden/{data,caddy/data,caddy/config}
cd /opt/vaultwarden
The Docker Compose File
Below is the complete docker-compose.yml. I'm using Caddy as the reverse proxy because it handles Let's Encrypt certificate issuance and renewal entirely automatically — no certbot cron jobs, no manual renewal scripts. I prefer Caddy over Nginx Proxy Manager for minimal setups like this because there's one fewer container and the Caddyfile syntax is genuinely readable.
cat > /opt/vaultwarden/docker-compose.yml <<'EOF'
version: "3.9"
networks:
proxy:
driver: bridge
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
networks:
- proxy
volumes:
- ./data:/data
environment:
DOMAIN: "https://vault.yourdomain.com"
SIGNUPS_ALLOWED: "false" # disable after creating your account
INVITATIONS_ALLOWED: "true"
WEBSOCKET_ENABLED: "true"
LOG_LEVEL: "warn"
EXTENDED_LOGGING: "true"
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
networks:
- proxy
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
EOF
SIGNUPS_ALLOWED: "true" only long enough to create your first account. Flip it to false and run docker compose up -d again immediately after. An open registration endpoint is an invitation for strangers to fill your vault server with junk — or worse.Writing the Caddyfile
Caddy's configuration is intentionally terse. The reverse_proxy directive forwards traffic to Vaultwarden, and the /notifications/hub path handles WebSocket connections for real-time vault sync across devices:
cat > /opt/vaultwarden/Caddyfile <<'EOF'
vault.yourdomain.com {
encode gzip
# WebSocket endpoint for live sync
reverse_proxy /notifications/hub vaultwarden:3012
# Everything else goes to the main Vaultwarden HTTP port
reverse_proxy vaultwarden:80 {
header_up X-Real-IP {remote_host}
}
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
}
EOF
Replace vault.yourdomain.com with your actual subdomain in both the docker-compose.yml environment variable and the Caddyfile. Caddy will automatically obtain and renew a Let's Encrypt TLS certificate for that domain the first time it starts, as long as port 80 is reachable for the ACME HTTP-01 challenge.
Bringing the Stack Up
cd /opt/vaultwarden
docker compose pull
docker compose up -d
docker compose logs -f
Watch the logs for a line from Caddy like certificate obtained successfully. It usually takes under 30 seconds. Once you see it, navigate to https://vault.yourdomain.com in your browser. You should land on the Vaultwarden login page with a green padlock.
dig +short vault.yourdomain.com — or (2) port 80 is blocked by a cloud firewall rule separate from UFW. DigitalOcean, for example, has both a Droplet-level firewall and a cloud firewall that you need to configure independently.Creating Your First Account and Locking Down Registration
Register your admin account through the web UI at https://vault.yourdomain.com/#/register. Once that's done, immediately update the compose file:
# Edit SIGNUPS_ALLOWED to false in docker-compose.yml, then:
docker compose up -d vaultwarden
Vaultwarden also ships an admin panel at /admin. To enable it, generate a secure token and add it to your environment block:
# Generate a random admin token (do not use this exact string)
openssl rand -base64 48
# Add to docker-compose.yml environment section:
# ADMIN_TOKEN: "paste-your-generated-token-here"
docker compose up -d vaultwarden
The admin panel at https://vault.yourdomain.com/admin lets you manage users, send invitations, view diagnostics, and force 2FA enrollment — all without touching the command line again.
Persistent Backups
All Vaultwarden data lives in /opt/vaultwarden/data. The most critical file is data/db.sqlite3. I back this up nightly with a simple cron job that copies it to an off-site object store, but at minimum you should snapshot the entire data directory regularly. If you're on DigitalOcean, Droplet Backups cover the whole volume automatically for 20% of the Droplet cost.
# Add to root's crontab (crontab -e)
# Runs at 02:00 daily, keeps 7 days of backups
0 2 * * * sqlite3 /opt/vaultwarden/data/db.sqlite3 ".backup '/opt/vaultwarden/data/db-backup-$(date +\%F).sqlite3'" && find /opt/vaultwarden/data -name 'db-backup-*.sqlite3' -mtime +7 -delete
Connecting the Bitwarden Client Apps
Every official Bitwarden client supports a custom server URL. In the browser extension or mobile app, find the Self-hosted environment option before logging in and enter your full URL: https://vault.yourdomain.com. That's it. Your existing Bitwarden browser extensions, the Android app, the iOS app, and the desktop clients all work without any additional configuration. The experience is identical to the cloud version.
Optional: Enable Fail2ban for Brute-Force Protection
Vaultwarden logs failed logins in /opt/vaultwarden/data/vaultwarden.log. If you have fail2ban installed on the host, you can watch that file and ban offending IPs. This is especially important if your vault is publicly accessible. I cover the full fail2ban setup in the fail2ban and CrowdSec hardening tutorial.
Wrapping Up
You now have a fully operational, HTTPS-secured, Bitwarden-compatible password manager running on your own infrastructure — no subscription fees, no third-party data custody. The whole stack uses under 50 MB of RAM at idle, which means it sits happily alongside other services on even the smallest VPS.
Two natural next steps from here: enable TOTP two-factor authentication on your Vaultwarden account (Settings → Two-step Login in the web vault), and set up Watchtower to keep the vaultwarden/server image updated automatically — critical for a security-sensitive application like this. Both are low-effort additions that meaningfully raise the security bar on your self-hosted vault.
Discussion