Overview
Trusting a third-party SaaS with your most sensitive credentials is a calculated risk. Breaches at password manager vendors have happened — and they will happen again. Vaultwarden is a lightweight, MIT-licensed, unofficial Bitwarden server implementation written in Rust. It speaks the full Bitwarden API, meaning every official Bitwarden client (browser extensions, desktop, mobile, CLI) works against it without modification.
This project walks through a production-quality deployment: Vaultwarden behind a Caddy reverse proxy that auto-issues a Let's Encrypt TLS certificate, WebSocket notifications for real-time vault sync, SQLite persistence, an admin panel protected by an Argon2-hashed token, and fail2ban brute-force protection. By the end you will have a fully self-sovereign password manager accessible from any Bitwarden client.
What you will build:
- Vaultwarden server running in Docker
- Caddy reverse proxy with automatic HTTPS and HTTP/2
- WebSocket endpoint for push notifications
- Encrypted, offsite-ready backup strategy
- Fail2ban rules watching Vaultwarden auth logs
- Hardened configuration: signups locked, 2FA enforced, invite-only access
Architecture
Internet
│
▼
[ Caddy :443 ] ─── auto TLS (Let's Encrypt)
│
├── /notifications/hub ──► vaultwarden:3012 (WebSocket)
│
└── /* ──► vaultwarden:80 (HTTP API + Web Vault)
│
[ SQLite DB ]
/data/db.sqlite3
│
[ RSA Keys ]
/data/rsa_key*
│
[ Attachments ]
/data/attachments/
All Bitwarden clients (browser extensions, desktop apps, mobile apps) connect to https://vault.your-domain.com using the standard Bitwarden protocol. Caddy sits in front, terminates TLS, and routes WebSocket connections to port 3012 and all other traffic to port 80 on the Vaultwarden container.
Minimum requirements:
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU |
| RAM | 256 MB | 512 MB |
| Disk | 1 GB | 5 GB |
| OS | Any Docker host | Ubuntu 22.04 LTS |
| Domain | Required (HTTPS mandatory) | Subdomain of owned domain |
HTTPS is non-negotiable. Browsers block the Web Crypto API on plain HTTP, which breaks password encryption entirely. You need a real domain with a valid TLS certificate.
Prerequisites
- A Linux host with Docker Engine and Docker Compose v2 installed
- A domain name you control (e.g.,
vault.example.com) with an A record pointing to your server's public IP - Ports 80 and 443 open on your firewall/router
- Root or sudo access on the host
Verify Docker is ready:
docker --version # Docker Engine 24.x or later
docker compose version # Docker Compose v2.xStep 1 — Directory Structure
Create a clean project directory to hold your compose file, Caddy config, and persistent data:
mkdir -p ~/vaultwarden/{data,caddy/{config,data}}
cd ~/vaultwardenYour layout will be:
~/vaultwarden/
├── docker-compose.yml
├── .env
├── Caddyfile
├── data/ # Vaultwarden persistent data (SQLite, keys, attachments)
└── caddy/
├── config/ # Caddy runtime config (persisted)
└── data/ # Caddy TLS certificates (persisted)
Step 2 — Generate a Secure Admin Token
Vaultwarden's admin panel is protected by a token. Use Argon2 hashing (recommended over plain text) so the raw secret never lives in the container environment:
# Generate a random 48-char secret
SECRET=$(openssl rand -base64 48)
echo "Save this secret: $SECRET"
# Hash it with Argon2 using the Vaultwarden image itself
docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp
# When prompted, paste your $SECRET value
# Copy the full $argon2id$... output stringYou will use the raw $SECRET when logging into /admin, and the hashed $argon2id$... string as the ADMIN_TOKEN environment variable.
Step 3 — Environment File
Create .env in your project directory. Never commit this file to version control.
cat > .env << 'EOF'
# Domain — must match your DNS A record
DOMAIN=https://vault.example.com
# Admin panel — paste the full $argon2id$... hash here
ADMIN_TOKEN=$argon2id$v=19$m=65540,t=3,p=4$XXXXXXXXXXXXXXXXXXXX
# SMTP (optional — enables email verification, 2FA recovery, invites)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USERNAME=no-reply@example.com
SMTP_PASSWORD=your_smtp_password
SMTP_FROM=no-reply@example.com
SMTP_FROM_NAME=Vaultwarden
# Timezone for accurate log timestamps (critical for fail2ban)
TZ=America/Edmonton
EOFReplace vault.example.com with your actual subdomain and fill in your SMTP credentials if you have them. SMTP enables email 2FA recovery and user invitations — recommended but not required for initial setup.
Step 4 — Docker Compose
# docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: always
volumes:
- ./data:/data
environment:
DOMAIN: ${DOMAIN}
ADMIN_TOKEN: ${ADMIN_TOKEN}
SIGNUPS_ALLOWED: "true" # Set false after creating your account
WEBSOCKET_ENABLED: "true"
LOG_FILE: /data/vaultwarden.log
LOG_LEVEL: info
EXTENDED_LOGGING: "true"
TZ: ${TZ}
# SMTP (uncomment if configured in .env)
# SMTP_HOST: ${SMTP_HOST}
# SMTP_PORT: ${SMTP_PORT}
# SMTP_SECURITY: ${SMTP_SECURITY}
# SMTP_USERNAME: ${SMTP_USERNAME}
# SMTP_PASSWORD: ${SMTP_PASSWORD}
# SMTP_FROM: ${SMTP_FROM}
# SMTP_FROM_NAME: ${SMTP_FROM_NAME}
networks:
- vw_net
caddy:
image: caddy:2-alpine
container_name: caddy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
networks:
- vw_net
depends_on:
- vaultwarden
networks:
vw_net:
driver: bridgeIf Caddy already runs as your main reverse proxy for other services, add Vaultwarden to its existing Caddyfile and skip the Caddy service here.
Step 5 — Caddyfile
# Caddyfile
vault.example.com {
encode gzip
# WebSocket endpoint — must route before the main proxy
reverse_proxy /notifications/hub vaultwarden:3012 {
header_up X-Real-IP {remote_host}
}
# All other traffic to Vaultwarden HTTP port
reverse_proxy vaultwarden:80 {
header_up X-Real-IP {remote_host}
}
}Replace vault.example.com with your domain. Caddy automatically obtains and renews a Let's Encrypt certificate on first start. The WebSocket path /notifications/hub must come first in the Caddyfile — Caddy matches routes top-to-bottom.
Step 6 — Launch the Stack
# Start in detached mode
docker compose up -d
# Tail logs to watch for startup errors
docker compose logs -f --tail 50Watch for these lines confirming a clean start:
vaultwarden | INFO vaultwarden::db::sqlite::mod > SQLite database opened successfully
vaultwarden | INFO vaultwarden::api > Listening on http://0.0.0.0:80
caddy | {"level":"info","msg":"serving initial configuration"}
caddy | {"level":"info","msg":"certificate obtained successfully"}
Navigate to https://vault.example.com — you should see the Bitwarden-compatible web vault UI with a valid TLS certificate.
Step 7 — Create Your Admin Account
- Open
https://vault.example.comin your browser - Click Create account
- Register with your email and a strong master password (this is the only password you need to remember — make it count)
- Log in and verify the vault loads correctly
Step 8 — Admin Panel & Security Hardening
Navigate to https://vault.example.com/admin and enter your plain-text $SECRET (not the hash).
Disable public signups
Once your accounts are created, lock down registration:
# Edit .env
SIGNUPS_ALLOWED=false
# Restart to apply
docker compose restart vaultwardenOr toggle it in the admin panel under General Settings → Allow new signups.
Enforce 2FA
In the admin panel → Users → click any user → Enable 2FA.
For organisation-wide enforcement: create an Organization inside the vault, then set the 2FA policy under Organization Settings → Policies → Require two-step login.
Invite-only mode
With signups disabled, use the admin panel to send email invitations to specific addresses. This is how you onboard additional users without reopening public registration.
Review admin panel settings to check
| Setting | Recommended Value |
|---|---|
| Allow new signups | Disabled |
| Allow invitations | Enabled |
| Require email verification | Enabled (if SMTP configured) |
| Password iterations (PBKDF2) | 600,000 (default) |
| Disable 2FA remember | Enabled for higher security |
| Domain | Matches your DOMAIN env var |
Step 9 — Fail2Ban Integration
Vaultwarden logs failed authentication attempts to /data/vaultwarden.log. Fail2ban reads these logs and bans IPs after repeated failures.
Install fail2ban on the host:
sudo apt install fail2ban -yCreate the filter at /etc/fail2ban/filter.d/vaultwarden.conf:
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\..*$
^.*TOTP, email or Duo code is incorrect\. Try again\. IP: <ADDR>\..*$
ignoreregex =Create the jail at /etc/fail2ban/jail.d/vaultwarden.conf:
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /home/YOUR_USER/vaultwarden/data/vaultwarden.log
maxretry = 5
bantime = 1h
findtime = 10mReplace YOUR_USER with your actual username and adjust logpath to the absolute path of your data/ directory.
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwardenYou should see the jail as active with 0 bans initially.
Step 10 — Backup Strategy
Vaultwarden stores everything under ./data/. A safe backup requires the container to be stopped (or at minimum not writing) to avoid a corrupt SQLite snapshot.
Simple nightly backup script — save as /usr/local/bin/vaultwarden-backup.sh:
#!/bin/bash
set -euo pipefail
COMPOSE_DIR="/home/YOUR_USER/vaultwarden"
BACKUP_DIR="/mnt/backup/vaultwarden"
DATE=$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"
# Pause the container briefly for a consistent snapshot
docker compose -f "$COMPOSE_DIR/docker-compose.yml" stop vaultwarden
# Archive and compress
tar -czf "$BACKUP_DIR/vaultwarden-$DATE.tar.gz" -C "$COMPOSE_DIR" data/
# Restart immediately
docker compose -f "$COMPOSE_DIR/docker-compose.yml" start vaultwarden
# Retain 30 days of backups
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +30 -delete
echo "Backup completed: vaultwarden-$DATE.tar.gz"chmod +x /usr/local/bin/vaultwarden-backup.sh
# Schedule daily at 3:00 AM
echo "0 3 * * * root /usr/local/bin/vaultwarden-backup.sh >> /var/log/vaultwarden-backup.log 2>&1" \
| sudo tee /etc/cron.d/vaultwarden-backupFor offsite storage, pipe the archive through rclone copy to an S3-compatible bucket, Backblaze B2, or another cloud provider. Encrypt the archive with gpg --symmetric before uploading if you want zero-knowledge offsite backups.
Testing
Functional verification
# Health endpoint
curl -s https://vault.example.com/alive
# Expected: "" (empty 200 OK)
# Version info
curl -s https://vault.example.com/api/config | jq .version
# WebSocket connectivity (requires wscat: npm i -g wscat)
wscat -c wss://vault.example.com/notifications/hub
# Should connect and stay openClient connectivity
- Install the Bitwarden browser extension (Chrome/Firefox/Edge)
- Click the extension icon → Settings → Self-hosted environment
- Set Server URL to
https://vault.example.com - Log in with your credentials — your vault should sync within seconds
Test the same process on the Bitwarden mobile app (iOS/Android) and the Bitwarden desktop app to confirm cross-platform compatibility.
TLS check
curl -vI https://vault.example.com 2>&1 | grep -E "SSL|TLS|issuer|expire"Verify Let's Encrypt is the issuer and check the expiry — Caddy auto-renews 30 days before expiration.
Fail2ban verification
# Deliberately fail login 5 times from a test machine, then check:
sudo fail2ban-client status vaultwarden
# You should see the test IP in the "Banned IP list"
# Unban when done testing
sudo fail2ban-client set vaultwarden unbanip YOUR_TEST_IPDeployment Notes
When running Vaultwarden behind an existing Traefik, Nginx, or Caddy instance, skip the dedicated Caddy service and instead add your routing rules to the existing proxy. The key points for any reverse proxy:
- Forward the real client IP via
X-Real-IPorX-Forwarded-For(required for fail2ban to ban the correct address) - Route
/notifications/hubto port 3012 (WebSocket), all other paths to port 80 - Ensure WebSocket upgrade headers are passed:
Connection: Upgrade,Upgrade: websocket - TLS termination happens at the proxy; Vaultwarden itself speaks plain HTTP internally
For a Traefik-based homelab, labels on the Vaultwarden container handle routing automatically:
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"
# WebSocket router
- "traefik.http.routers.vw-ws.rule=Host(`vault.example.com`) && Path(`/notifications/hub`)"
- "traefik.http.routers.vw-ws.entrypoints=websecure"
- "traefik.http.services.vw-ws.loadbalancer.server.port=3012"Extensions & Next Steps
Migrate to PostgreSQL
SQLite is fine for personal or small team use. For larger deployments, migrate to PostgreSQL for better concurrent write performance:
# Add to docker-compose.yml
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: vaultwarden
POSTGRES_USER: vw
POSTGRES_PASSWORD: strong_password_here
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:Then add DATABASE_URL: postgresql://vw:strong_password_here@postgres/vaultwarden to the Vaultwarden environment. Vaultwarden handles the schema migration automatically on first start.
Organizations and Sharing
Create an Organization inside the vault for shared credentials (team passwords, infrastructure secrets). Organizations support:
- Role-based collections (admin, manager, user, custom)
- Granular item permissions per collection
- Event logs for audit trails (Enterprise plan equivalent)
- 2FA policy enforcement
Emergency Access
Enable emergency access under Account Settings → Emergency Access. This lets a designated trusted contact request access to your vault after a configurable wait period — a critical feature for self-hosted deployments where there is no vendor support recovery path.
LDAP / Active Directory Sync
The vaultwarden-ldap sidecar container syncs users from LDAP/AD automatically:
vaultwarden-ldap:
image: vividboarder/vaultwarden_ldap:latest
environment:
VAULTWARDEN_URL: http://vaultwarden:80
VAULTWARDEN_ADMIN_TOKEN: your_plain_text_secret
LDAP_HOST: ldap://your-dc
LDAP_BIND_DN: cn=svc-vw,ou=services,dc=example,dc=com
LDAP_BIND_PASSWORD: ldap_service_account_password
LDAP_SEARCH_BASE_DN: ou=users,dc=example,dc=comMonitoring
Mount the log file into a Promtail/Loki container for centralized log ingestion, or set up a Grafana dashboard that tracks failed login attempts, vault syncs, and container health over time.
Vaultwarden Updates
docker compose pull vaultwarden
docker compose up -d vaultwardenAlways take a backup before pulling a new image. Vaultwarden uses SQLite migrations — downgrading after a migration is not supported, so keep recent backups before upgrading.
Vaultwarden gives you a fully featured, client-compatible Bitwarden deployment on hardware you control. Combined with automatic TLS, WebSocket sync, brute-force protection, and encrypted backups, this stack handles both personal and small-team credential management without relying on any third-party vault service.