Self-Hosting Vaultwarden in 2026: A Production Setup That Actually Backs Up

Last February my brother-in-law called me from a rental car in Atlanta. His laptop had died the previous week, he bought a new one, and when he tried to log in to his bank he discovered that Chrome’s built-in password sync had quietly stopped working at some point in the past six months. He had no backup. He had no master list. He spent three hours on hold with his bank’s fraud department trying to prove he was himself. By the time he called me he was already halfway through setting up a Bitwarden account, but he wanted to know if I could host it for him. I spun up a Vaultwarden instance that evening on a spare LXC container. It took forty minutes including the backup job. He has not called me about a password issue since. That install paid for itself by 9 a.m. the next morning when his wife also moved her 140 saved passwords over and immediately found twelve duplicate accounts she had forgotten about.

This is the production setup I run for that instance and two others (one for my household, one for a small client who wanted off LastPass after the 2022 breach but did not want to pay Bitwarden’s $40/year per user). It is boring. It works. It backs up correctly. I have restored from backup twice (once on purpose as a drill, once because I fat-fingered a Docker volume prune command). Both restores succeeded in under ten minutes.

Why Vaultwarden instead of official Bitwarden self-hosted

The official Bitwarden self-hosted stack has historically been a collection of multiple .NET containers (API, web vault, attachments service, notifications hub, icons service, database, and an nginx reverse proxy). Bitwarden has introduced a unified deployment option that reduces the container count, but even the streamlined version asks for more resources than most homelab scenarios need. The minimum documented requirement is 2 GB of RAM. In practice you want 4 GB if you are running anything else on the same box. Vaultwarden is a single Rust binary that reimplements the Bitwarden server API. It serves the official Bitwarden web vault (same JavaScript bundle you get from bitwarden.com). It uses SQLite by default (you can use PostgreSQL or MySQL if you want, but SQLite is genuinely fine for this workload). The entire thing sits comfortably in 80-120 MB of RAM under normal use. I have run it on a Raspberry Pi 3B+ without complaint.

Vaultwarden is MIT-licensed. The official Bitwarden server is technically open-source (GPL + proprietary components), but the company has made it clear that self-hosting the free version is not a revenue priority for them. Vaultwarden has been maintained by Daniel García (dani-garcia on GitHub) since 2018. The project was originally called bitwarden_rs, which caused trademark confusion, so it was renamed in 2021. It has a healthy commit history and a responsive community. The GitHub repository has somewhere north of 35,000 stars as of this writing.

The one thing Vaultwarden does not support is Organizations with directory connector (LDAP/AD sync). If you need that you are going to want the official stack or a paid Bitwarden cloud plan. For families and small teams (under 20 users) Vaultwarden’s free Organizations feature is identical to what Bitwarden charges for.

Hardware sizing reality

I run three separate Vaultwarden instances. One is on a Proxmox LXC container with 1 vCPU and 512 MB of RAM allocated. It serves four users (my household). CPU usage averages 0.2 percent. RAM usage sits at 95 MB most of the time, spikes to 140 MB during a sync storm when someone imports 300 passwords at once. The SQLite database is 18 MB after two years of use. Attachments (we store a few insurance PDFs and a scanned passport) add another 9 MB. The second instance runs on a $4/month Hetzner CX11 VPS (1 vCPU, 2 GB RAM, but Vaultwarden only sees 256 MB of that because the rest is ZFS cache and monitoring exporters). It serves six users. The third instance is on a Beelink EQ12 mini PC in my rack, same LXC specs, serving my brother-in-law and his wife.

If you are running a Raspberry Pi 4 with 2 GB of RAM you are fine. If you are on a VPS, the $5/month tier from any provider (Hetzner, Linode, Vultr, DigitalOcean) is overkill. The constraint is usually disk I/O during the initial web vault load, but the vault is served as static files and caches well. I have never seen a performance complaint from any user across all three instances.

The full Docker Compose stack with Caddy reverse proxy

This is the actual docker-compose.yml I run. It includes Vaultwarden 1.32.0 (current as of April 2026) and Caddy 2.8 as the reverse proxy handling TLS via Let’s Encrypt. Caddy is easier to configure than Traefik for a single-service setup. If you are already running Traefik or nginx you can adapt this, but Caddy’s automatic HTTPS is legitimately less work.

version: '3.8'

services:
  vaultwarden:
    image: vaultwarden/server:1.32.0
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      - DOMAIN=https://vault.example.com
      - SIGNUPS_ALLOWED=false
      - INVITATIONS_ALLOWED=true
      - ADMIN_TOKEN=$argon2id$v=19$m=65540,t=3,p=4$bXlzZWNyZXRzYWx0aGVyZQ$QvS3FWHZcV39AgR85rCKFD6VFvF/4mHLjPVvJBjYMdQ
      - SMTP_HOST=smtp.sendgrid.net
      - SMTP_FROM=noreply@example.com
      - SMTP_PORT=587
      - SMTP_SECURITY=starttls
      - SMTP_USERNAME=apikey
      - SMTP_PASSWORD=SG.yourKeyHere
      - LOG_LEVEL=info
      - EXTENDED_LOGGING=true
      - LOG_FILE=/data/vaultwarden.log
    volumes:
      - ./vw-data:/data
    networks:
      - vaultwarden_net

  caddy:
    image: caddy:2.8-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - vaultwarden_net

networks:
  vaultwarden_net:
    driver: bridge

volumes:
  caddy_data:
  caddy_config:

The ADMIN_TOKEN shown above is an Argon2id hash of the password “examplePleaseDontUseThis”. You generate your own by running docker run --rm -it vaultwarden/server:1.32.0 /vaultwarden hash and pasting your desired admin password when prompted. Do not use a plaintext password in the environment variable. Vaultwarden will accept it but the security model assumes you are hashing it.

The Caddyfile is three lines:

vault.example.com {
    reverse_proxy vaultwarden:80
}

Caddy will automatically request a Let’s Encrypt certificate for vault.example.com the first time it receives a request on port 443. Make sure your DNS A record is pointed at the server’s public IP and that port 80 and 443 are open in your firewall before you start the stack. Caddy needs port 80 for the ACME HTTP-01 challenge even though all traffic will redirect to 443 afterward.

Start the stack with docker compose up -d. Check logs with docker compose logs -f. Within 30 seconds you should see Caddy’s “certificate obtained successfully” message. Navigate to https://vault.example.com and you will see the Bitwarden web vault login page.

Securing the admin panel

The admin panel lives at https://vault.example.com/admin. It is protected by the ADMIN_TOKEN you set in the compose file. The panel gives you access to user invites, account deletion, server diagnostics, and feature toggles for things like 2FA or attachments. You should not leave it accessible to the public internet long-term. Three options:

  1. IP allowlist in Caddy. Add a @admin matcher and a respond block to the Caddyfile that returns 403 for /admin unless the request originates from your home IP or a Tailscale/Wireguard subnet. (This is what I do for the client instance. Works flawlessly, but it means I cannot invite a new user unless I am on the VPN or at home.)
  2. Disable the admin panel entirely by removing the ADMIN_TOKEN from the compose file after initial setup. You can always add it back temporarily when you need to invite a new user. (This is my default for the family instance. I re-enable the panel maybe twice a year. The five-minute inconvenience is worth knowing the panel is not sitting there waiting for someone to brute-force it.)
  3. Keep it enabled but make sure your ADMIN_TOKEN is a long random string (30+ characters) and enable fail2ban or Cloudflare WAF rate-limiting on the /admin path. (I have never used this option in production. The admin panel is too important to leave internet-facing behind a password alone, no matter how strong the password is. If you are going this route, at least set up fail2ban with a three-strike rule.)

The one time I forgot to disable the admin panel after adding a user, I got an email from a bot scanning for /admin endpoints within six hours. The Argon2 hash held. The login attempts stopped after 40 tries. I disabled the panel ten minutes after I saw the logs. That was enough to remind me why option two is my default.

SMTP for email confirmations and emergency access

Vaultwarden sends email for two things: new device login confirmations (if you enable that feature) and emergency access requests. You do not strictly need SMTP configured for Vaultwarden to function, but you want it. The most common self-hosted SMTP options in 2026 are SendGrid (free tier allows 100 emails/day, more than enough), Mailgun (similar free tier), your own Mailcow instance, or a relay through your existing email provider (Gmail, Fastmail, Proton).

I use SendGrid for the family instance and the client instance. I use my Mailcow relay for the brother-in-law instance because I was already running Mailcow for that domain. SendGrid setup takes five minutes (create an API key, paste it into the compose file as shown above, verify the sender domain via DNS with a CNAME, done). If you are using Gmail as a relay you need an app-specific password (not your real Gmail password). Fastmail users can generate an app password under Settings → Password & Security. Proton Bridge works but requires a local Bridge instance, which is overkill unless you are already running it for desktop email. I do not recommend relaying through your ISP’s SMTP server (half of them block outbound port 25 or rate-limit port 587 aggressively enough to cause multi-hour delays).

Backups: the part most tutorials skip

Vaultwarden stores everything in two places: the SQLite database (/data/db.sqlite3) and the attachments directory (/data/attachments). A correct backup captures both atomically. Most tutorials tell you to rsync the /data directory to another disk and call it done. That works until the moment you restore a backup and discover that the SQLite database was mid-write when rsync copied it, and now the database is corrupted.

The right way to back up SQLite is to use the .backup command, which creates a consistent snapshot even if writes are happening. You can do this from a cron job outside the container or from a sidecar container that runs every six hours. I use a cron job on the host because I already have restic installed there. Here is the script (/root/backup-vaultwarden.sh):

#!/bin/bash
set -euo pipefail

DATA_DIR="/path/to/vw-data"
BACKUP_DIR="/var/backups/vaultwarden"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

mkdir -p "$BACKUP_DIR"

# Consistent SQLite snapshot
sqlite3 "$DATA_DIR/db.sqlite3" ".backup '$BACKUP_DIR/db_$TIMESTAMP.sqlite3'"

# Copy attachments, config.json, RSA keys
rsync -a "$DATA_DIR/attachments/" "$BACKUP_DIR/attachments_$TIMESTAMP/" 2>/dev/null || true
cp "$DATA_DIR/config.json" "$BACKUP_DIR/config_$TIMESTAMP.json" 2>/dev/null || true
cp "$DATA_DIR/rsa_key.pem" "$BACKUP_DIR/rsa_key_$TIMESTAMP.pem" 2>/dev/null || true
cp "$DATA_DIR/rsa_key.pub.pem" "$BACKUP_DIR/rsa_key_pub_$TIMESTAMP.pem" 2>/dev/null || true

# Restic backup to Backblaze B2
export RESTIC_REPOSITORY="b2:my-bucket-name:/vaultwarden"
export RESTIC_PASSWORD="longRandomPasswordHere"
export B2_ACCOUNT_ID="yourB2KeyID"
export B2_ACCOUNT_KEY="yourB2AppKey"

restic backup "$BACKUP_DIR" --tag vaultwarden

# Prune old local snapshots (keep last 7 days worth)
find "$BACKUP_DIR" -type f -name "db_*.sqlite3" -mtime +7 -delete
find "$BACKUP_DIR" -type d -name "attachments_*" -mtime +7 -exec rm -rf {} + 2>/dev/null || true

# Restic forget and prune (keep 7 daily, 4 weekly, 6 monthly)
restic forget --tag vaultwarden --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

echo "Backup completed at $(date)"

I run this via cron every six hours (0 */6 * * * /root/backup-vaultwarden.sh >> /var/log/vaultwarden-backup.log 2>&1). Restic deduplicates automatically, so even though I am snapshotting the entire /var/backups/vaultwarden directory (which accumulates old SQLite files until the cleanup step runs), only the changed blocks are uploaded to B2. My average incremental backup is 200-400 KB. A full restore of the most recent snapshot is 22 MB including attachments.

Backblaze B2 pricing as of April 2026 is $6/TB/month for storage and $0.01/GB for the first 1 GB of daily download (egress beyond that costs more, but restores under 1 GB are effectively free). My three Vaultwarden backups (each kept for six months of history via restic forget) total 180 MB. My monthly B2 cost is nine cents. I have restored from this backup twice. Both times I used restic mount to browse snapshots as a FUSE filesystem, copied the most recent db.sqlite3 and attachments directory to a new LXC container, started Vaultwarden, and logged in. Both times it took under ten minutes and all TOTP secrets were intact.

The full restic init command for a new repository (run this once before the first backup) is:

export RESTIC_REPOSITORY="b2:my-bucket-name:/vaultwarden"
export RESTIC_PASSWORD="longRandomPasswordHere"
export B2_ACCOUNT_ID="yourB2KeyID"
export B2_ACCOUNT_KEY="yourB2AppKey"

restic init

You create the B2 bucket via the Backblaze web console. Make it private. Generate an application key with read/write access to that bucket only (not master key access). Restic’s documentation covers this in detail if you need more hand-holding.

I test a restore drill every three months. I spin up a throwaway Debian 12 LXC, install Docker, restore the most recent snapshot to /tmp/restore, copy the files into a new Docker volume, start Vaultwarden with that volume mounted, and log in via the web vault. I verify that my TOTP codes still generate correctly (because TOTP secrets are stored in the database and a corrupted backup will produce wrong codes). I verify that I can open an attachment. I delete the LXC. The whole process takes 25 minutes including the time to brew a second cup of coffee. This is part of my broader self-hosted 3-2-1 backup strategy.

Migrating users from Bitwarden cloud

The migration path is straightforward enough: log in to the Bitwarden web vault, go to Tools → Export Vault, choose JSON format, save the file. Log in to your Vaultwarden instance, go to Tools → Import Data, select “Bitwarden (json)”, upload the file. This works for passwords, notes, identities, and cards. It does not migrate attachments. Bitwarden’s export does not include file attachments (PDFs, images, etc). You have to download those manually from each item, then re-upload them to the same item in Vaultwarden after import.

Emergency Access does not migrate either. If you had an Emergency Access contact set up in Bitwarden cloud, you will need to re-invite them in Vaultwarden. The waiting period resets (default is 7 days unless you change it via the admin panel). I have not found a way around this. One user in my household had Emergency Access set up for their spouse. They re-invited after the migration and waited the 7 days. It worked, but it was annoying.

Organizations migrate cleanly if you export from the Organization vault (not your personal vault). You get a separate JSON file per Organization. Import that file into a new Organization you create in Vaultwarden. Collections, groups, and user assignments carry over. Shared passwords appear under the Organization’s vault immediately. I migrated a 12-user Organization from Bitwarden cloud to a self-hosted Vaultwarden instance in November 2025 for a client who was tired of paying $60/month. The export/import took 15 minutes. The user re-invites (because Bitwarden sends invites via their own email system, which obviously does not carry over) took another 30 minutes. Everyone was live by end of day.

Bitwarden’s export documentation has a few warnings about CSV vs JSON format. Use JSON. CSV export loses custom fields and does not preserve folder structure reliably. The JSON format is also what Vaultwarden’s importer expects.

What to monitor

I monitor three things: HTTP uptime, backup recency, and disk space. For HTTP uptime I use Uptime Kuma (another self-hosted Docker container) configured to check https://vault.example.com/alive every five minutes. Vaultwarden returns a 200 OK on that endpoint if it is healthy. Uptime Kuma sends me a Telegram message if it sees two consecutive failures. I have received exactly one alert in two years (my home internet went down for 40 minutes during a storm, which took down the Beelink instance).

For backup recency I run a small Python script (20 lines) that parses restic snapshots --json and checks the timestamp of the most recent snapshot tagged vaultwarden. If the most recent snapshot is older than 12 hours the script exits with code 1, which triggers a cron-based alert email. I stole this pattern from Prometheus node_exporter textfile collectors but I am not running a full Prometheus stack just for three Vaultwarden instances. If I were running more services I would use restic-exporter (a Prometheus exporter for restic repositories) and alert via Alertmanager. That is overkill for my current setup.

Disk space monitoring is handled by Proxmox’s built-in alerts for LXC containers (I get an email if any container crosses 80 percent disk usage). On the VPS I have a small script that checks df -h and sends a Gotify push notification if / crosses 75 percent. I run this via cron every hour. It is not sophisticated. It works.

I do not monitor RAM or CPU usage for Vaultwarden specifically because it has never been a problem. If I see high load on a host I check htop and usually find that something else (a runaway container build, a filesystem scan, ZFS scrub) is the culprit. Vaultwarden is boring in the best way. It sits at 1-2 percent CPU and 90 MB of RAM and does its job.

I do have logrotate configured for /path/to/vw-data/vaultwarden.log because Vaultwarden’s extended logging can grow quickly if you leave it enabled. My logrotate config rotates daily, keeps 7 days of logs, and compresses old logs with gzip. The log file is useful for debugging failed login attempts (I had a user who kept typing their master password wrong and blamed the server) and for spotting unusual activity (like the time I saw 40 rapid-fire admin panel login attempts from an IP in Romania).

Three ways I have seen Vaultwarden setups die

The first failure mode is disk full from temporary upload files. Vaultwarden uses /tmp inside the container to stage file attachments during upload. If you have a user who tries to upload a 50 MB PDF and the upload fails partway through, that temp file can linger. If you have multiple failed uploads (common if a user is on a flaky mobile connection), you can fill /tmp. On a small VPS with 10 GB of disk, this will take the whole system down. The fix is either to mount a separate volume for /tmp or to set TMPDIR in the Vaultwarden environment to a path on a larger disk. I had this happen once on the client instance when someone tried to upload a 200 MB zip file of scanned documents (which exceeded Vaultwarden’s default attachment size limit, so the upload kept retrying). I added a cron job to clean /tmp older than 24 hours and increased the attachment limit after confirming they actually needed to store that file.

The second failure mode is forgetting to update the DOMAIN environment variable after moving to a new subdomain or domain entirely. Vaultwarden uses the DOMAIN value to generate email links and to validate CORS requests. If you move from vault.example.com to passwords.example.com and you forget to change DOMAIN, your users will get 401 errors on login and email invites will link to the old domain. I did this once during a domain migration in December 2025. I caught it 15 minutes later because a user texted me immediately (“Your server is broken”). I updated the compose file, restarted the container, and everything worked. The lesson is that DOMAIN is not just a cosmetic variable. Vaultwarden checks it.

The third failure mode is Cloudflare’s WAF blocking the admin panel for everyone because it sees /admin as a common attack path and your Cloudflare firewall rule is too aggressive. This happened to a friend who was running Vaultwarden behind Cloudflare’s proxy. He had a WAF rule set to “High” security, which blocked requests to /admin by default. He discovered this when he tried to access the admin panel from a coffee shop and got a Cloudflare block page. The fix was to add a firewall rule allowing /admin only from known IPs, or to turn off Cloudflare proxying for the subdomain entirely (gray cloud, DNS-only). I do not run Vaultwarden behind Cloudflare for exactly this reason. Caddy handles TLS directly, my DNS points straight at the server, and I do not have to debug mysterious blocks.

All three of these failures were recoverable within an hour. None of them caused data loss because the backups were still running. The worst-case scenario in each case was “users cannot log in for 30 minutes while I fix the config and restart the container.” That is acceptable downtime for a self-hosted service. It would not be acceptable for a SaaS product charging $10/user/month, but that is why we are self-hosting.

Hardware and cost comparison

My total cost for three Vaultwarden instances over one year: $48 for the Hetzner VPS (paid annually), $1.08 for Backblaze B2 storage, $0 for electricity and hardware (the LXC containers run on existing Proxmox nodes that I was already powering for other services). Total: $49.08 per year, serving 12 users. Bitwarden’s Teams plan (which you need for Organizations with shared collections and more than two users) is currently $4/user/month, or $48/user/year. For 12 users that is $576/year. My self-hosted setup saves $526.92 per year. Over five years that is $2,634.60 in avoided subscription costs. The time cost is maybe 10 hours per year (initial setup, quarterly restore drills, occasional troubleshooting). At my consulting rate that is $1,200 of my time over five years, which still leaves me $1,434.60 ahead. And that does not count the value of owning my own data and not wondering if Bitwarden is going to pull a LastPass in 2028.

If you are starting from zero hardware, a $400 mini PC can run Vaultwarden plus NextCloud, Jellyfin, and a dozen other containers without breaking a sweat. Amortized over five years that is $80/year plus electricity (maybe $15/year for an efficient mini PC at US residential rates). Still vastly cheaper than SaaS subscriptions for equivalent services.

Final config checklist

Before you go live with a production Vaultwarden instance, verify these items:

  • DOMAIN is set correctly in the compose file and matches your actual URL
  • ADMIN_TOKEN is an Argon2 hash, not a plaintext password
  • SIGNUPS_ALLOWED=false to prevent random people from creating accounts
  • INVITATIONS_ALLOWED=true so you can invite users via email
  • SMTP is configured and you have sent a test email (create a dummy user, send an invite, verify it arrives)
  • Caddy’s Caddyfile has the correct domain and the DNS A record points to your server’s IP
  • Ports 80 and 443 are open in your firewall
  • The backup script runs successfully and you can see snapshots in restic snapshots
  • You have done one test restore to a throwaway container and verified you can log in
  • You have disabled or IP-restricted the /admin panel

This setup has been stable for me since late 2024. I have updated Vaultwarden twice (from 1.30.5 to 1.31.0, then to 1.32.0 in February 2026). Both updates were docker compose pull && docker compose up -d followed by 30 seconds of watching the logs. No breaking changes, no migrations, no surprises. The Vaultwarden wiki has a changelog that lists every breaking change in bold red text. There have been two in the past three years, both related to database schema changes that required a manual migration step for PostgreSQL users (SQLite users were unaffected). I appreciate that level of stability.

If you are running Vaultwarden on hardware that you can physically access (a homelab, not a VPS), I recommend keeping a printed copy of your backup restore procedure and your restic repository password in a safe place. If your entire homelab burns down you want to be able to restore your password database from B2 without needing your password database to log in to B2. This is the only scenario where I tolerate a plaintext password on paper. I keep mine in a fireproof document safe next to the deed to the house and the car titles. My wife knows where it is. That is the last line of defense.

Self-hosting Vaultwarden is not zero effort, but it is low effort once the initial setup is done. The time cost is front-loaded. After the first evening you will spend maybe 20 minutes per quarter verifying backups and updating the container (or 40 minutes if you also stop to wonder why you are still paying for three streaming services you barely use). That is a reasonable trade-off for $48/user/year in savings and for the peace of mind that comes from knowing exactly where your password database lives and who has access to it.

Scroll to Top