Self-Host Vaultwarden (Bitwarden Server)
Deploy a self-hosted Vaultwarden (Bitwarden-compatible) password server using Docker Compose, nginx reverse proxy, Let's Encrypt TLS, and automated backups.
Before you start
- ▸Docker Engine 24+ and the docker compose plugin installed
- ▸A domain name with a DNS A record pointing to your server's public IP
- ▸Ports 80 and 443 open in your firewall
- ▸A non-root user with sudo privileges and membership in the docker group
Vaultwarden is a lightweight, unofficial Bitwarden-compatible server written in Rust. It runs comfortably on a cheap VPS or even a Raspberry Pi, uses a fraction of the resources of the official server, and lets you keep full control of your password vault. This guide walks through a production-ready setup: Docker Compose, an nginx reverse proxy with TLS, the admin panel, automated backups, and connecting clients.
Prerequisites and Architecture Overview
You need a server with a public IP, a domain name (or subdomain) pointed at it, and Docker + Docker Compose installed. Vaultwarden itself runs as a container; nginx runs alongside it and terminates TLS. Certbot manages certificates. Data lives in a named volume that your backup script snapshots nightly.
- Port 80 and 443 open inbound (for Let's Encrypt and HTTPS traffic)
- Docker Engine 24+ and the
docker composeplugin (v2 syntax) - A DNS A record for
vault.example.comalready propagated - A non-root user with sudo and membership in the
dockergroup
Step 1 — Generate the Admin Token
Vaultwarden's admin panel is protected by a bcrypt-hashed token, not a plain-text password. Generate the hash before writing any config files.
docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp
You'll be prompted to type a passphrase. Copy the $argon2id$... or $2y$... hash that is printed — you'll paste it into the Compose file shortly. Store the original passphrase somewhere safe; that's what you'll type in the browser.
Step 2 — Create the Project Directory and docker-compose.yml
mkdir -p /opt/vaultwarden && cd /opt/vaultwarden
Create docker-compose.yml with the following content, replacing the placeholder values:
cat > docker-compose.yml <<'EOF'
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
volumes:
- vw-data:/data
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "false" # disable after creating your account
ADMIN_TOKEN: "PASTE_BCRYPT_HASH_HERE"
SMTP_HOST: "smtp.example.com" # optional, for email verification
SMTP_PORT: "587"
SMTP_SECURITY: "starttls"
SMTP_USERNAME: "[email protected]"
SMTP_PASSWORD: "smtp-password"
SMTP_FROM: "[email protected]"
networks:
- proxy
nginx:
image: nginx:stable-alpine
container_name: nginx-vw
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- /var/www/certbot:/var/www/certbot:ro
networks:
- proxy
depends_on:
- vaultwarden
volumes:
vw-data:
networks:
proxy:
EOF
Step 3 — Configure nginx
mkdir -p nginx/conf.d
cat > nginx/conf.d/vaultwarden.conf <<'EOF'
server {
listen 80;
server_name vault.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name vault.example.com;
ssl_certificate /etc/letsencrypt/live/vault.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vault.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Increase body size for large vault imports
client_max_body_size 128M;
location / {
proxy_pass http://vaultwarden:80;
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;
}
# WebSocket support for live sync
location /notifications/hub {
proxy_pass http://vaultwarden:3012;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /notifications/hub/negotiate {
proxy_pass http://vaultwarden:80;
}
}
EOF
Step 4 — Obtain a TLS Certificate
Bring up nginx temporarily on port 80 only (comment out the HTTPS server block or use the HTTP-only config), then run Certbot standalone — or use the webroot method if nginx is already running.
# Install Certbot on Debian/Ubuntu
sudo apt install -y certbot
# Install Certbot on Fedora/RHEL
sudo dnf install -y certbot
# Install Certbot on Arch
sudo pacman -S certbot
sudo certbot certonly --standalone \
-d vault.example.com \
--email [email protected] \
--agree-tos --no-eff-email
Certbot writes certificates to /etc/letsencrypt/live/vault.example.com/. The nginx container mounts that path read-only, so renewals are picked up automatically. Add a systemd timer or cron job for renewal:
sudo systemctl enable --now certbot.timer # Debian/Ubuntu package creates this
# or manually:
sudo crontab -e
# add: 0 3 * * * certbot renew --quiet && docker exec nginx-vw nginx -s reload
Step 5 — Start the Stack
cd /opt/vaultwarden
docker compose up -d
docker compose logs -f # watch for errors, Ctrl-C when stable
Navigate to https://vault.example.com — you should see the Vaultwarden login page. Create your account now while SIGNUPS_ALLOWED is still true. Then immediately edit docker-compose.yml, set it to false, and redeploy:
docker compose up -d vaultwarden
Step 6 — Access the Admin Panel
Visit https://vault.example.com/admin and enter the passphrase you chose in Step 1 (not the hash). The admin panel lets you invite users, configure 2FA policy, review diagnostics, and send test emails. Critically, confirm the SMTP test email works here before inviting other users — Bitwarden clients require email verification for some operations.
Step 7 — Automate Backups
Vaultwarden stores everything — the encrypted vault database, attachments, and RSA keys — under the vw-data volume, which maps to /var/lib/docker/volumes/vaultwarden_vw-data/_data by default. Back up that directory while the container is running; SQLite is safe to copy live because Vaultwarden uses WAL mode, but a brief stop is safer for a consistent snapshot.
cat > /opt/vaultwarden/backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/opt/vaultwarden/backups"
DATE=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
docker compose -f /opt/vaultwarden/docker-compose.yml stop vaultwarden
sqlite3 /var/lib/docker/volumes/vaultwarden_vw-data/_data/db.sqlite3 \
".backup '$BACKUP_DIR/db-$DATE.sqlite3'"
docker compose -f /opt/vaultwarden/docker-compose.yml start vaultwarden
# Archive attachments and sends
tar -czf "$BACKUP_DIR/attachments-$DATE.tar.gz" \
/var/lib/docker/volumes/vaultwarden_vw-data/_data/attachments \
/var/lib/docker/volumes/vaultwarden_vw-data/_data/sends 2>/dev/null || true
# Keep 30 days of backups
find "$BACKUP_DIR" -mtime +30 -delete
EOF
chmod +x /opt/vaultwarden/backup.sh
# Schedule nightly at 02:30
(crontab -l 2>/dev/null; echo "30 2 * * * /opt/vaultwarden/backup.sh >> /var/log/vw-backup.log 2>&1") | crontab -
Offload backups to a remote location (S3, rsync to a second server, etc.). A backup that lives on the same machine as the data is not a backup.
Step 8 — Connect Clients
All official Bitwarden clients support custom server URLs. The procedure is the same across apps:
- Open the Bitwarden app (browser extension, desktop, or mobile).
- On the login screen, click or tap the gear / region selector.
- Set Server URL to
https://vault.example.com. - Log in with the account you created in Step 5.
The browser extension is available for Chrome/Chromium, Firefox, and other browsers. The desktop client works on Linux under both X11 and Wayland. On mobile, install the official Bitwarden app from F-Droid, Google Play, or the App Store — it will talk to your server, not Bitwarden's cloud.
Verification
# Check containers are running
docker compose ps
# Check Vaultwarden version and health
curl -s https://vault.example.com/alive
# Confirm WebSocket endpoint
curl -s -o /dev/null -w "%{http_code}" \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
https://vault.example.com/notifications/hub
The /alive endpoint returns Ok. The WebSocket endpoint returns 400 from a plain curl, which is correct — it means nginx is proxying it rather than blocking it.
Troubleshooting
"Invalid admin token" at /admin
You likely pasted the hash with whitespace, or used a plain-text token instead of a bcrypt hash. Re-run the docker run ... hash command, copy the output carefully, and make sure the entire $2y$... string including dollar signs is inside double quotes in the Compose file.
WebSocket notifications not working (vault doesn't sync live)
Double-check the /notifications/hub and /notifications/hub/negotiate location blocks in nginx. Omitting the proxy_http_version 1.1 and Upgrade headers is the most common mistake. Check nginx logs with docker compose logs nginx-vw.
Certificate renewal breaks nginx
After certbot renew the nginx container needs a reload to pick up the new cert. Add && docker exec nginx-vw nginx -s reload to your renewal cron job or deploy hook.
Clients show "cannot connect to server"
Verify that port 443 is open in your firewall. On nftables/ufw/firewalld:
# ufw
sudo ufw allow 443/tcp
# firewalld
sudo firewall-cmd --permanent --add-service=https && sudo firewall-cmd --reloadFrequently asked questions
- Is Vaultwarden safe to use for real passwords?
- Yes, with caveats. Vaultwarden uses the same client-side encryption as the official Bitwarden service, so your passwords are encrypted before they leave your device. The risk is operational: you are responsible for keeping the server patched, backed up, and properly secured. Run it behind TLS, restrict the admin endpoint, and keep Docker images updated.
- Can I migrate my existing Bitwarden cloud vault to Vaultwarden?
- Yes. In the official Bitwarden web vault or app, go to Tools → Export Vault (encrypted JSON format is safest). Log into your Vaultwarden instance and import that file via Tools → Import Data. Attachments must be re-uploaded manually.
- Why use nginx instead of Caddy or Traefik?
- This guide uses nginx because it is ubiquitous and its configuration is easy to audit. Caddy is a popular alternative that handles TLS automatically without a separate certbot step. Traefik suits setups with many containers. The Vaultwarden wiki has example configs for all three.
- Do I need to expose port 3012 publicly for WebSockets?
- No. Nginx proxies WebSocket traffic from port 443 to the container's internal port 3012. Port 3012 should not be published to the host or opened in your firewall — the proxy_pass in the nginx config handles the internal routing.
- How do I update Vaultwarden?
- Pull the new image and recreate the container. Run: docker compose pull && docker compose up -d. Vaultwarden applies any database migrations automatically on startup. Check the GitHub releases page for breaking changes before upgrading major versions.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.