Self-host Vaultwarden, the Bitwarden-compatible password manager
Follow this on your own server. Start free, no card needed
Bitwarden’s hosted plans are fine until you’d rather not keep your team’s passwords on someone else’s server, or you just want a vault that answers at your own domain. Vaultwarden is a from-scratch Rust reimplementation of the Bitwarden server API, compatible with the real Bitwarden browser extensions, mobile apps and CLI, and light enough to run comfortably on the cheapest server a cloud provider sells. By the end of this guide you’ll have it running behind HTTPS with live sync working, an admin token that isn’t sitting in plain text, sign-ups locked down to just your team, and a backup you can actually restore from.
What you’ll need
- A server with Docker and the Docker Compose plugin. A Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) is more than enough for a household or small team’s vault.
- A domain or subdomain you control, pointed at the server’s IP address (an A record for something like
vault.yourdomain.com). - Any Bitwarden-compatible client: the official browser extension, desktop app, mobile app, or CLI all work unmodified against Vaultwarden.
- SSH access to the server.
Step 1: Generate a hashed admin token, not a plain one
Vaultwarden’s /admin page, where you manage users, organizations and server settings, is protected by a single ADMIN_TOKEN value. A plain string still works, but the project’s own documentation calls that out as insecure, since anyone who can read your environment variables or compose file gets the token verbatim. The current recommendation is to store it as an Argon2id hash instead, using Vaultwarden’s own built-in hashing command:
docker run --rm -it vaultwarden/server:1.37.4 /vaultwarden hash
This prompts for a password and prints a PHC-format hash that looks like $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash> (the string above is illustrative, yours will have a different salt and hash). That whole string is what you set as ADMIN_TOKEN; Vaultwarden hashes incoming attempts and compares, so the real token lives in your head, not in a config file anyone can grep.
Put it in a .env file next to your docker-compose.yml, not committed to version control:
ADMIN_TOKEN='$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$c29tZWhhc2h2YWx1ZQ'
A .env file needs no escaping of the $ characters. If you instead paste the hash straight into the environment: block of docker-compose.yml, every $ has to be doubled to $$ or Compose tries to interpret it as variable substitution, which is one more reason the .env file is the easier path.
Step 2: Write the Docker Compose file
services:
vaultwarden:
image: vaultwarden/server:1.37.4
restart: unless-stopped
environment:
DOMAIN: "https://vault.yourdomain.com"
SIGNUPS_ALLOWED: "false"
ADMIN_TOKEN: "${ADMIN_TOKEN}"
volumes:
- ./vaultwarden-data:/data
expose:
- "80"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- vaultwarden
volumes:
caddy-data:
caddy-config:
1.37.4 is the current stable release tag on Docker Hub as of writing; pin a real version rather than latest so an upgrade is something you run on purpose. DOMAIN has to be the exact HTTPS URL clients will reach the server at, Vaultwarden uses it to build links in emails and to validate WebAuthn and passkey origins, so a mismatch here is a real functional bug, not just cosmetic. SIGNUPS_ALLOWED starts true by default on a fresh Vaultwarden install, meaning anyone who finds the URL can create an account; set it to "false" once your own accounts exist, which is Step 5 below. The data directory is a plain bind mount rather than a named volume specifically so a backup is just copying a folder, see Step 6.
Step 3: Point the domain at the server and start the stack
Create an A record for your subdomain pointing at the server’s public IP, then bring the containers up:
docker compose up -d
Caddy requests a certificate from Let’s Encrypt automatically the first time it sees traffic for the domain in your Caddyfile (Step 4), which requires the DNS record to already be resolving and ports 80 and 443 to be reachable from the internet, not just from your own machine.
Step 4: Add the Caddy reverse proxy
vault.yourdomain.com {
reverse_proxy vaultwarden:80
}
That single reverse_proxy line is also the entire WebSocket setup. Vaultwarden used to need a separate port (3012) and a WEBSOCKET_ENABLED flag for the live-sync notifications that push a new or changed item to your other open clients instantly; since version 1.29.0 those notifications run over the same port as the rest of the app, and Caddy’s reverse_proxy directive already forwards the Upgrade and Connection headers a WebSocket handshake needs without any extra configuration. If you’re adapting this for nginx instead, that’s the one header pair you’d have to add by hand that Caddy gives you for free.
Step 5: Create your account, then lock the door
Visit https://vault.yourdomain.com, register the first account through the normal sign-up form, and confirm you can log in and add a test item. Once every account your team needs exists, flip sign-ups off:
environment:
SIGNUPS_ALLOWED: "false"
Run docker compose up -d again to apply it. From here on, new accounts are created from the /admin page using your Argon2-hashed token from Step 1, with invites sent via email if you’ve configured SMTP, or by sharing an invite link manually if you haven’t.
Step 6: Back up the data directory
Everything that matters, encrypted vault data, attachments, and server config, lives under ./vaultwarden-data. A daily tarball to somewhere off the server is enough for most setups:
tar czf vaultwarden-backup-$(date +%F).tar.gz ./vaultwarden-data
Keep a handful of recent backups and copy at least one of them somewhere other than the server itself, a second server, object storage, anywhere that survives the original disk failing. Restoring is the same operation in reverse: stop the stack, extract the tarball back over ./vaultwarden-data, start the stack again.
Step 7: Upgrade without losing data
docker compose pull vaultwarden
docker compose up -d vaultwarden
Vaultwarden runs any schema migrations it needs automatically on startup against the SQLite (or configured external) database in /data, so a normal upgrade is just pulling the new image and recreating the one container. Read the release notes for the version you’re jumping to before a multi-version jump, since the project occasionally calls out a breaking change for self-hosters in a specific release.
The shortcut: do it with Railyard
Railyard ships Vaultwarden as a one-click application template (vaultwarden in the templates catalog), built from the official vaultwarden/server image with a persistent /data volume already wired up and ADMIN_TOKEN set as the one secret you fill in yourself. Deploy it directly: app.railyard.run/deploy?template=vaultwarden.
Two differences from the manual path worth knowing: the template currently tracks the floating latest tag rather than a pinned version, so where this guide has you choose and upgrade a specific version yourself, Railyard moves with upstream automatically. And sign-ups are open by default here too, the same SIGNUPS_ALLOWED=false step in Step 5 still applies once your team’s accounts exist, it’s just an environment variable on the app’s Settings page instead of a line in docker-compose.yml. See Deploy with a button for how one-click templates work in general.
Troubleshooting
HTTPS never issues, Caddy just keeps retrying. Almost always a DNS problem: confirm vault.yourdomain.com already resolves to the server’s IP from outside your own network (not just from the server itself), and that nothing upstream is blocking inbound 80 or 443. Caddy’s own logs (docker compose logs caddy) print the exact ACME error if Let’s Encrypt rejected the request.
Live sync doesn’t update other devices, but the vault itself works fine. Check that nothing between the client and Caddy is stripping WebSocket upgrade headers, a CDN or an additional proxy layer in front of Caddy is the usual culprit, since Caddy itself handles this correctly out of the box as covered in Step 4.
docker compose up fails with a port already in use. Something else on the server is already bound to 80 or 443, commonly a previous reverse proxy or an existing web server. sudo ss -tlnp | grep ':80\|:443' shows what’s holding the port.
The admin page rejects a token you’re sure is correct. Confirm you’re pasting the full PHC hash string including the $argon2id$ prefix as ADMIN_TOKEN, not just the password you hashed. If you edited the value directly inside docker-compose.yml rather than a .env file, check every $ is doubled to $$, a single unescaped $ gets swallowed by Compose’s own variable substitution before Vaultwarden ever sees it.
Writes to /data fail with a permissions error. This shows up most often after restoring a backup as root when the container runs as a different user; chown -R the directory to match whatever UID the container logs show it’s running as, then restart.
FAQ
Is Vaultwarden actually compatible with the real Bitwarden apps? Yes, it implements the same API the official apps and browser extensions speak, so you point them at your own server’s URL instead of bitwarden.com and nothing else changes on the client side.
Can I use a database other than the default SQLite file? Vaultwarden supports MySQL and PostgreSQL as well, through a DATABASE_URL environment variable, which is worth it once more than a handful of people depend on the vault and you want backups handled by your existing database tooling instead of a tarball.
Do I need SMTP configured to invite people? No, you can share an invite link manually from the /admin page without ever setting up outbound mail, though real email invites are friendlier for anyone who isn’t already comfortable pasting a raw link into a browser.
What happens if I lose the server without a backup? Every vault item is gone, there’s no password reset path back into data that was never backed up anywhere else, which is the whole reason Step 6 isn’t optional in practice even though Docker will happily run without it.
Is this safe for a whole company, or just personal use? Organizations, shared collections and group permissions are all supported, the same as hosted Bitwarden; the main thing that scales less gracefully by hand is everything in Step 6 and Step 7, which is exactly the part a managed option takes off your plate.
Once it’s running, a self-hosted vault mostly disappears into the background, the real ongoing work is the backup and upgrade habit from Step 6 and Step 7, not the initial setup.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.