Self-Host Vaultwarden: Your Own Bitwarden-Compatible Password Manager
Run a private, end-to-end-encrypted Bitwarden server in two Docker containers with Caddy handling HTTPS.
What you'll build
A private, Bitwarden-compatible password vault running as two Docker containers: Vaultwarden for the API and web vault, and Caddy in front of it terminating HTTPS with an automatic Let's Encrypt certificate. The official Bitwarden clients point at your domain and work unchanged.
Prerequisites
- A Linux host with a public IP and ports 80 and 443 reachable from the internet. Caddy needs both to pass the ACME challenge.
- A DNS A (or AAAA) record for your vault hostname pointing at that host. This tutorial uses
vault.example.com; substitute yours everywhere. - Docker Engine with the Compose plugin. Verified against Docker Engine 29.7.2 and Docker Compose v5.4.0. The old standalone
docker-composev1 binary won't parse this file. - Verified images:
vaultwarden/server:1.37.2(released 2026-08-22) andcaddy:2(currently 2.11.4). The Vaultwarden tag is pinned so you decide when your password manager upgrades.
1. Create the project directory
mkdir -p ~/vaultwarden/caddy && cd ~/vaultwarden
Vaultwarden's SQLite database, RSA signing keys, and attachments live under one /data volume; Caddy's certificates live under its own. Both get bind-mounted here so this one directory holds everything.
2. Generate a hashed admin token
The /admin page manages users and settings. It's protected by ADMIN_TOKEN, which should be an Argon2id hash rather than a plain string. The hasher ships in the image:
docker run --rm -it vaultwarden/server:1.37.2 /vaultwarden hash --preset owasp
It prompts twice for a password (minimum 8 characters) and prints a line starting with $argon2id$v=19$. The password is what you'll type at /admin; the hash goes in the Compose file next. --preset bitwarden uses heavier parameters if the host has RAM to spare.
3. Write the Compose file
Create compose.yaml:
services:
vaultwarden:
image: vaultwarden/server:1.37.2
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "true"
ADMIN_TOKEN: "$$argon2id$$v=19$$m=19456,t=2,p=1$$REPLACE$$WITH_YOUR_HASH"
volumes:
- ./vw-data:/data
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./caddy:/etc/caddy
- ./caddy-data:/data
- ./caddy-config:/config
Two things matter here. DOMAIN must be the full https:// URL clients will use; the wiki warns that leaving it unset breaks features in confusing ways. And every $ in the hash is doubled to $$, because Compose treats $name as variable interpolation.
Vaultwarden publishes no ports. Only Caddy is reachable from outside; it talks to Vaultwarden over the Compose network on port 80.
4. Write the Caddyfile
Create caddy/Caddyfile:
vault.example.com {
encode zstd gzip
reverse_proxy vaultwarden:80 {
header_up X-Real-IP {remote_host}
}
}
That's the whole proxy. Caddy obtains and renews the certificate, redirects HTTP to HTTPS, and proxies WebSockets without extra directives. Since Vaultwarden 1.31 the sync WebSocket runs on the main port, so the old separate port 3012 is gone. X-Real-IP gives Vaultwarden the client's address for login logs and fail2ban.
The Caddyfile is mounted as a directory rather than a single file because editors like vim replace the file's inode, which breaks caddy reload on a single-file bind mount.
5. Start the stack and create your account
docker compose up -d
Open https://vault.example.com, click Create account, and register with a strong master password. The server never sees that password, and losing it means losing the vault.
Then close registration so nobody else can sign up:
sed -i 's/SIGNUPS_ALLOWED: "true"/SIGNUPS_ALLOWED: "false"/' compose.yaml
docker compose up -d
Compose recreates only the Vaultwarden container. Future users get in via Invite on the /admin page.
Verify it works
Check that Vaultwarden came up and bound its port:
docker compose logs vaultwarden | grep launched
vaultwarden | [2026-08-27 11:41:58.312][start][INFO] Rocket has launched from http://0.0.0.0:80
Now hit the health endpoint through the proxy:
curl https://vault.example.com/alive
It returns the server time as a JSON string:
"2026-08-27T11:42:03.115318Z"
Then in the Bitwarden browser extension, set Logging in on to Self-hosted with https://vault.example.com as the Server URL, and log in with the account from step 5. Last, open https://vault.example.com/admin and enter your admin password to confirm the token hash works.
Troubleshooting
The "argon2id" variable is not set. Defaulting to a blank string. printed by docker compose up, followed by the admin page rejecting your password with Invalid admin token, please try again. You pasted the hash with single $. Compose interpolated $argon2id, $v, and $m as empty variables and stored a mangled token. Double every $ in compose.yaml and run docker compose up -d again.
The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it at /admin. ADMIN_TOKEN is unset or empty in the container. Check what Vaultwarden received with docker compose exec vaultwarden env | grep ADMIN_TOKEN; if it's blank, the interpolation problem above is the usual cause.
This browser requires HTTPS to use the web vault when you open the site. You reached Vaultwarden over plain HTTP, usually by browsing to the host IP or an http:// URL. The web vault uses the browser's Web Crypto API, which only exists in secure contexts. Use the https:// hostname; there's no toggle to disable this.
Caddy logs could not get certificate from issuer repeatedly and the site shows a certificate error. Let's Encrypt can't reach your host on port 80 or 443, or DNS doesn't point here yet. Check dig +short vault.example.com returns your public IP and that the cloud firewall allows both ports. Caddy retries automatically once the path is open, so no restart is needed.
Next steps
Set up backups first: vw-data/db.sqlite3, rsa_key.*, and attachments/ are the whole vault, and the wiki's backup page shows the sqlite3 .backup command that's safe while the server runs. Configure SMTP so invitations and email 2FA work, then follow the hardening guide to disable password hints, run as a non-root user, and add fail2ban. To upgrade, bump the image tag and run docker compose pull && docker compose up -d; the release notes call out releases that newer clients require.
Sources & further reading
- Using Docker Compose - vaultwarden wiki — github.com
- Enabling admin page - vaultwarden wiki — github.com
- Proxy examples - vaultwarden wiki — github.com
- Releases - dani-garcia/vaultwarden — github.com
- caddy - Official Image — hub.docker.com
- Interpolation - Compose file reference — docs.docker.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.