Introduction
Password reuse and weak credentials remain the single biggest driver of account compromise for SMBs and homelabs alike. A password manager fixes this, but cloud-hosted vaults put your entire credential store behind a third party's uptime, pricing, and breach history. Vaultwarden (formerly bitwarden_rs) is a lightweight, Rust-based server implementation of the Bitwarden API — it's a drop-in replacement that works with the official Bitwarden browser extensions, mobile apps, and CLI, but runs on hardware you control, using a fraction of the resources of Bitwarden's own self-hosted stack.
In this guide you will:
- Deploy Vaultwarden with Docker Compose
- Put it behind a reverse proxy with TLS and security headers
- Lock down admin access and disable open registration
- Enable two-factor authentication (TOTP) enforcement
- Automate encrypted backups of the vault database
- Verify the deployment and troubleshoot common issues
Prerequisites
Before starting, confirm you have:
- A Linux host with Docker Engine and the Compose plugin installed
- A DNS record (e.g.
vault.example.com) pointed at the host's public or LAN IP - A reverse proxy already terminating TLS for other services (Traefik, Caddy, or nginx + certbot)
- At least 512MB of free RAM and 1GB of disk for the vault data volume — Vaultwarden's footprint is small compared to Bitwarden's official Java-based server
Step 1: Generate an Admin Token
Vaultwarden's /admin panel controls users, organizations, and diagnostics. It must be protected with a token — never leave it disabled on an internet-facing instance.
# Generate a strong Argon2 admin token hash
docker run --rm vaultwarden/server:latest /vaultwarden hashEnter a strong passphrase when prompted. Copy the resulting $argon2id$... hash — you'll paste it into the environment file in Step 2.
Step 2: Create the Docker Compose Stack
Create a project directory and an .env file to keep secrets out of the compose file itself.
mkdir -p /opt/vaultwarden/data
cd /opt/vaultwarden# .env
DOMAIN=https://vault.example.com
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$...'
SIGNUPS_ALLOWED=false
INVITATIONS_ALLOWED=true
WEBSOCKET_ENABLED=true# docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN}
ADMIN_TOKEN: ${ADMIN_TOKEN}
SIGNUPS_ALLOWED: ${SIGNUPS_ALLOWED}
INVITATIONS_ALLOWED: ${INVITATIONS_ALLOWED}
WEBSOCKET_ENABLED: ${WEBSOCKET_ENABLED}
LOG_FILE: /data/vaultwarden.log
volumes:
- ./data:/data
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.vaultwarden.rule=Host(`vault.example.com`)"
- "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
networks:
proxy:
external: trueSIGNUPS_ALLOWED=false is critical — it prevents anyone who finds the URL from creating an account. New users are added instead through invitations sent from the admin panel or by a logged-in org admin.
Bring the stack up:
docker compose up -d
docker compose logs -f vaultwardenStep 3: Harden the Reverse Proxy
If you're using Traefik, add security headers via a middleware so the vault isn't served without HSTS or frame protection:
# traefik dynamic config
http:
middlewares:
vaultwarden-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
frameDeny: true
contentTypeNosniff: true
browserXssFilter: trueAttach it to the router with an additional label:
labels:
- "traefik.http.routers.vaultwarden.middlewares=vaultwarden-headers"For nginx, the equivalent is adding Strict-Transport-Security, X-Frame-Options: DENY, and X-Content-Type-Options: nosniff to the site's server block.
Step 4: Lock Down the Admin Panel
Log in to https://vault.example.com/admin using the plaintext passphrase you hashed in Step 1 (not the hash itself). From here:
- Under Settings, confirm signups are disabled and set an SMTP relay so invitation and 2FA emails deliver reliably
- Under Users, review any accounts and revoke ones you don't recognize
- Consider restricting
/adminat the reverse-proxy layer to your LAN or VPN CIDR as a second layer of defense:
# Traefik IP allowlist middleware for /admin
http:
middlewares:
admin-allowlist:
ipAllowList:
sourceRange:
- "10.0.0.0/8"
- "192.168.0.0/16"Step 5: Enforce Two-Factor Authentication
Vaultwarden supports TOTP, WebAuthn/FIDO2, email codes, and Duo. To require 2FA org-wide, create an organization from a user account, then under Organization Settings → Policies, enable "Require two-step login." Members who haven't configured 2FA will be blocked from vault access until they do.
Individual users enable TOTP from the Bitwarden client under Settings → Security → Two-step Login, scanning the QR code with an authenticator app.
Step 6: Automate Backups
The entire vault lives in /data — primarily db.sqlite3, the attachments/ directory, and rsa_key* files used for JWT signing. Losing the RSA keys invalidates all existing sessions and API keys, so back them up alongside the database.
#!/usr/bin/env bash
# /opt/vaultwarden/backup.sh
set -euo pipefail
BACKUP_DIR="/opt/vaultwarden/backups"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
# SQLite-safe snapshot without stopping the container
docker exec vaultwarden sqlite3 /data/db.sqlite3 ".backup '/data/db-backup.sqlite3'"
tar -czf "$BACKUP_DIR/vaultwarden-$TIMESTAMP.tar.gz" \
-C /opt/vaultwarden/data db-backup.sqlite3 attachments rsa_key.pem rsa_key.pub.pem
rm /opt/vaultwarden/data/db-backup.sqlite3
# Retain 14 days of backups
find "$BACKUP_DIR" -name "vaultwarden-*.tar.gz" -mtime +14 -deletechmod +x /opt/vaultwarden/backup.shSchedule it with cron and ship the archive off-host (rsync, rclone to object storage, etc.) — a backup that lives on the same disk as the vault doesn't protect against disk failure or ransomware.
0 3 * * * /opt/vaultwarden/backup.sh >> /var/log/vaultwarden-backup.log 2>&1Verification
Confirm the deployment end-to-end:
# Container is healthy
docker compose ps
# HTTPS responds with a valid cert
curl -sI https://vault.example.com | head -n 1
# API is reachable
curl -s https://vault.example.com/aliveInstall the Bitwarden browser extension or mobile app, choose "Self-hosted" in server settings, enter your domain, and log in with an invited account. Save a test entry, then confirm it syncs by checking the same vault from a second device.
Restore-test the backup at least once:
docker compose stop vaultwarden
tar -xzf backups/vaultwarden-<timestamp>.tar.gz -C /tmp/restore-test
# verify db-backup.sqlite3 opens cleanly
sqlite3 /tmp/restore-test/db-backup.sqlite3 ".tables"
docker compose start vaultwardenTroubleshooting
"Username or password is incorrect" on first login — Confirm SIGNUPS_ALLOWED was true at the moment you registered the first account, or use an admin-panel invite instead.
WebSocket sync not working (changes don't push to other devices) — Ensure WEBSOCKET_ENABLED=true is set and that your reverse proxy passes through the Upgrade and Connection headers on the /notifications/hub path.
Admin panel returns 404 — The admin interface is disabled entirely if ADMIN_TOKEN is unset or empty. Check docker compose logs vaultwarden for a startup warning confirming the admin route is active.
Emails (invites, 2FA codes) never arrive — Vaultwarden needs explicit SMTP configuration (SMTP_HOST, SMTP_FROM, SMTP_USERNAME, SMTP_PASSWORD); without it, invitation links must be copied manually from the admin panel and shared out-of-band.
Container restarts in a loop after an update — Check for a schema migration failure in the logs; restore the previous backup and pin the image to the last known-good tag before retrying.
Summary
Vaultwarden gives you a fully compatible Bitwarden vault without the resource overhead or vendor dependency of the official self-hosted stack. The critical hardening steps are non-negotiable for anything internet-facing: disable open signups, protect the admin token, terminate TLS properly, enforce 2FA at the organization level, and — above all — back up the SQLite database and RSA keys somewhere that isn't the same disk. A password manager is only as trustworthy as its recovery story.