Deploying a Secure Vaultwarden Password Manager with Docker and SSL
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
Trusting a cloud provider with your passwords is a calculated risk I stopped taking years ago. Vaultwarden — a lightweight, Rust-based reimplementation of the Bitwarden server — lets you run the entire Bitwarden ecosystem on hardware you control, for essentially zero ongoing cost. In this tutorial I'll walk you through a production-ready deployment using Docker Compose, Caddy as the reverse proxy with automatic SSL, and a few hardening steps that are easy to skip but really shouldn't be.
What You'll Need Before You Start
This guide assumes you have a VPS or home server running Ubuntu 22.04 or 24.04, with Docker Engine and Docker Compose v2 already installed. You'll also need a domain name with an A record pointing at your server's public IP — Caddy needs a publicly reachable hostname to obtain a Let's Encrypt certificate automatically. I run this on a Hetzner CX22 (2 vCPU, 4 GB RAM) and it barely registers in htop.
If you only want to expose Vaultwarden inside a Tailscale network rather than publicly, that's a valid path too, but you'll need to configure Caddy's tls directive with DNS challenge instead of HTTP challenge. I'll note where that changes things.
Directory Layout
I keep every service in its own subdirectory under /opt/containers. It makes backups trivial and prevents compose files from colliding.
sudo mkdir -p /opt/containers/vaultwarden/vw-data
sudo chown -R $USER:$USER /opt/containers/vaultwarden
cd /opt/containers/vaultwarden
The vw-data directory is where Vaultwarden stores its SQLite database, attachments, and icon cache. Back this up regularly — it's the only thing you need to restore a full Vaultwarden instance.
The Docker Compose File
Here's the complete docker-compose.yml. I'll explain the critical environment variables immediately after.
cat > /opt/containers/vaultwarden/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"
INVITATIONS_ALLOWED: "true"
ADMIN_TOKEN: ""
LOG_LEVEL: "warn"
EXTENDED_LOGGING: "true"
ROCKET_PORT: "80"
networks:
- proxy
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- proxy
networks:
proxy:
driver: bridge
EOF
ADMIN_TOKEN empty until you've generated a secure one with openssl rand -base64 48. If you deploy with the admin panel enabled and a weak token, anyone who finds your Vaultwarden instance can wipe it. I prefer disabling the admin panel entirely after initial setup by setting ADMIN_TOKEN to a blank string.A few environment variables deserve explanation:
- DOMAIN — Must match the HTTPS URL exactly. Vaultwarden uses this for WebAuthn and attachment URLs. Get it wrong and FIDO2 keys won't work.
- SIGNUPS_ALLOWED: "false" — Disable this immediately after creating your account. Otherwise anyone who discovers your URL can register.
- ROCKET_PORT: "80" — Vaultwarden uses the Rocket web framework. We're not exposing port 80 on the host directly; Caddy talks to the container on the internal Docker network.
Configuring Caddy for Automatic SSL
I prefer Caddy over Nginx Proxy Manager for a single-service setup like this because there are no UI sessions to expire, no database to corrupt, and the Caddyfile format is genuinely readable. Create the Caddyfile next to your compose file:
cat > /opt/containers/vaultwarden/Caddyfile << 'EOF'
vault.example.com {
reverse_proxy vaultwarden:80
header {
# Security headers
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "same-origin"
# Remove server identification
-Server
}
encode gzip
log {
output file /data/access.log {
roll_size 10mb
roll_keep 5
}
level WARN
}
}
EOF
That's genuinely the whole Caddy config. When the container starts, Caddy will reach out to Let's Encrypt, complete the HTTP-01 challenge on port 80, obtain a certificate, and start serving HTTPS on port 443. Renewals are automatic. I've been running this pattern for two years and have never had a certificate expire.
Firewall Rules Before You Launch
Before bringing the stack up, make sure UFW only exposes what's necessary. You do not want port 80 or 443 blocked — Caddy needs 80 for the ACME challenge and 443 for serving traffic.
# Allow SSH (don't skip this or you'll lock yourself out)
sudo ufw allow 22/tcp
# Allow HTTP and HTTPS for Caddy
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp # HTTP/3 QUIC
# Enable the firewall
sudo ufw enable
# Verify
sudo ufw status verbose
Note that I am not opening port 3012, which is Vaultwarden's WebSocket port for live sync notifications. Caddy handles WebSocket proxying transparently on port 443, so you don't need to expose 3012 to the outside world at all — Caddy's reverse_proxy directive handles the upgrade headers automatically.
Launching and Creating Your Account
With the files in place, bring the stack up:
cd /opt/containers/vaultwarden
docker compose up -d
# Watch the logs to confirm Caddy gets a certificate
docker compose logs -f caddy
You should see Caddy output something like certificate obtained successfully within about 30 seconds. Once it does, navigate to https://vault.example.com in your browser. You'll see the Bitwarden-compatible login page.
Create your account via the UI now, while SIGNUPS_ALLOWED is still true — wait, I had you set it to false from the start. So first, temporarily set it to "true", run docker compose up -d again to recreate the container with the new env var, create your account, then set it back to "false" and redeploy. It's a minor friction point, but it's the right default posture.
Once you're logged in, install the official Bitwarden browser extension or mobile app and point it at your custom server URL under Settings → Server URL. Everything just works — including Emergency Access, Send, and file attachments.
Backing Up Vaultwarden Data
Your entire Vaultwarden state lives in /opt/containers/vaultwarden/vw-data/. The SQLite database is at vw-data/db.sqlite3. I back this up with a simple cron job that compresses the directory and ships it to an S3-compatible bucket (I use Hetzner Object Storage for this):
# Add to crontab with: crontab -e
# Runs at 2 AM daily
0 2 * * * tar -czf /tmp/vaultwarden-backup-$(date +\%F).tar.gz \
-C /opt/containers/vaultwarden vw-data && \
aws s3 cp /tmp/vaultwarden-backup-$(date +\%F).tar.gz \
s3://your-backup-bucket/vaultwarden/ \
--endpoint-url https://your-s3-endpoint && \
rm /tmp/vaultwarden-backup-$(date +\%F).tar.gz
For a cleaner approach, look at vaultwarden-backup container images that handle SQLite-safe snapshots, but the tar approach works fine when Vaultwarden is idle during the backup window.
Keeping Vaultwarden Updated
Vaultwarden releases updates frequently and security patches do appear. I use Watchtower in monitor-only mode to get notified when a new image is available, then update manually:
cd /opt/containers/vaultwarden
# Pull the latest image
docker compose pull
# Recreate the container with the new image (downtime is seconds)
docker compose up -d
# Remove the old image layer
docker image prune -f
I deliberately avoid auto-updating password managers. The few seconds of manual intervention is worth the confidence that you tested the update before your vault went live.
Wrapping Up
You now have a fully functional, SSL-secured Vaultwarden instance running on hardware you control, with no third-party storing your encrypted vault. The total resource footprint is under 50 MB of RAM when idle. Your next steps should be setting up the Bitwarden client apps on all your devices, enabling two-factor authentication in the Vaultwarden web vault (TOTP works out of the box; FIDO2/WebAuthn also works as long as your DOMAIN variable is set correctly), and verifying your backup job is actually writing files where you expect. Test a restore before you need it.
Discussion