Self-Hosting Your Own Password Manager: Vaultwarden Setup and Hardening Guide

Self-Hosting Your Own Password Manager: Vaultwarden Setup and Hardening Guide

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

Your password manager is arguably the most sensitive piece of software you use — it holds the keys to everything. Trusting a third-party cloud service with that data has always made me a little uneasy, which is exactly why I moved to a self-hosted Vaultwarden instance two years ago and haven't looked back. Vaultwarden is a community-written, Rust-based server that implements the Bitwarden API, meaning every official Bitwarden client — mobile, browser extension, desktop — works against it perfectly, at a fraction of the resource footprint of the official Bitwarden server.

In this guide I'll walk you through deploying Vaultwarden with Docker Compose, fronting it with Caddy for automatic HTTPS, and then applying the hardening steps I consider non-negotiable before I'd trust it with production credentials. I'm running this on a DigitalOcean Droplet — a $6/month basic Droplet is more than enough — but the same steps apply to any Ubuntu 22.04 or 24.04 VPS or homelab machine.

Prerequisites

Before we start, make sure you have the following in place:

If you want a solid, affordable place to spin this up, DigitalOcean Droplets give you dependable uptime with a 99.99% SLA and predictable monthly pricing. Their one-click Docker marketplace image is also a quick way to get Docker pre-installed.

Directory Structure and Docker Compose File

I keep all my self-hosted services under /opt/services/, one directory per app. Create the Vaultwarden directory and the Caddy configuration directories now:

sudo mkdir -p /opt/services/vaultwarden/vw-data
sudo mkdir -p /opt/services/caddy/{config,data}
cd /opt/services/vaultwarden

Now create the docker-compose.yml. I use a single Compose file that defines both the Vaultwarden container and a Caddy reverse proxy container, joined on a shared internal network. Nothing in Vaultwarden is exposed directly to the internet — all traffic must flow through Caddy.

cat > /opt/services/vaultwarden/docker-compose.yml <<'EOF'
version: "3.9"

services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: "https://vault.yourdomain.com"
      SIGNUPS_ALLOWED: "false"
      INVITATIONS_ALLOWED: "false"
      ADMIN_TOKEN_FILE: "/run/secrets/vw_admin_token"
      LOG_LEVEL: "warn"
      SMTP_HOST: ""            # fill in if you want email 2FA / invites
      SMTP_FROM: ""
      SMTP_PORT: "587"
      SMTP_SECURITY: "starttls"
      SMTP_USERNAME: ""
      SMTP_PASSWORD: ""
    volumes:
      - ./vw-data:/data
      - ./secrets/vw_admin_token:/run/secrets/vw_admin_token:ro
    networks:
      - internal

  caddy:
    image: caddy:2-alpine
    container_name: caddy_vw
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - /opt/services/caddy/data:/data
      - /opt/services/caddy/config:/config
    networks:
      - internal

networks:
  internal:
    driver: bridge
    internal: false
EOF
Watch out: Notice that SIGNUPS_ALLOWED is set to "false" from the very first boot. If you leave it true, anyone who finds your URL can create an account. Create your own account immediately on first startup, then restart with signups disabled — or use the admin panel to invite specific email addresses only.

Creating the Admin Token Secret

Vaultwarden has an /admin panel that's protected by a single token. Instead of baking it into an environment variable in plain text (bad), I'm mounting it as a file secret. Generate an argon2 hash of a strong passphrase:

# Install argon2 if needed
sudo apt install -y argon2

# Generate a hashed token — use your own strong passphrase
echo -n "your-very-long-admin-passphrase" | argon2 "$(openssl rand -base64 16)" -e -id -k 65540 -t 3 -p 4

# Create the secrets directory and write the hash
mkdir -p /opt/services/vaultwarden/secrets
echo '$argon2id$v=19$m=65540,t=3,p=4$...' \
  > /opt/services/vaultwarden/secrets/vw_admin_token
chmod 600 /opt/services/vaultwarden/secrets/vw_admin_token

Replace the echo'd string with the actual argon2 output from the command above. Vaultwarden 1.30+ understands argon2 hashed tokens natively — you no longer need to store a raw string.

Configuring Caddy as the Reverse Proxy

I prefer Caddy over Nginx Proxy Manager for this use case because it handles Let's Encrypt certificates fully automatically — no certbot cron jobs, no manual renewal. The Caddyfile is also beautifully concise:

cat > /opt/services/vaultwarden/Caddyfile <<'EOF'
vault.yourdomain.com {
    encode zstd gzip

    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "no-referrer"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
        -Server
    }

    # Block direct admin access from the public internet
    # Comment this out to manage via browser when needed, then re-enable
    @admin_block {
        path /admin*
        not remote_ip 127.0.0.1/8 ::1/128
    }
    respond @admin_block "Forbidden" 403

    reverse_proxy vaultwarden:80 {
        header_up X-Real-IP {remote_host}
    }
}
EOF
Tip: The @admin_block matcher in the Caddyfile above blocks /admin access from any IP that isn't localhost. When I need to access the admin panel, I open an SSH tunnel first: ssh -L 8080:localhost:443 user@yourserver, then browse to https://vault.yourdomain.com/admin via the tunnel. This keeps the admin panel completely off the public internet without a separate VPN.

First Boot and Account Creation

Before starting, temporarily set SIGNUPS_ALLOWED: "true" in your Compose file just for the first boot. Then:

cd /opt/services/vaultwarden
docker compose up -d

# Watch logs to confirm healthy startup
docker compose logs -f vaultwarden

Open https://vault.yourdomain.com in your browser. Caddy will automatically obtain a Let's Encrypt certificate — this usually takes under 30 seconds. Create your account, verify it works, then edit the Compose file to set SIGNUPS_ALLOWED: "false" and restart:

docker compose up -d --force-recreate vaultwarden

Hardening: Fail2ban for Vaultwarden

Vaultwarden writes failed login attempts to its log file. I configure fail2ban to parse those logs and ban repeat offenders. First, install fail2ban:

sudo apt install -y fail2ban

# Create a Vaultwarden filter
sudo tee /etc/fail2ban/filter.d/vaultwarden.conf <<'EOF'
[INCLUDES]
before = common.conf

[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =
EOF

# Create the jail
sudo tee /etc/fail2ban/jail.d/vaultwarden.conf <<'EOF'
[vaultwarden]
enabled  = true
port     = 80,443
filter   = vaultwarden
logpath  = /opt/services/vaultwarden/vw-data/vaultwarden.log
maxretry = 5
bantime  = 1800
findtime = 300
EOF

sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden

Backup Strategy

All of Vaultwarden's data — the SQLite database, attachments, and sends — lives in the ./vw-data directory. Vaultwarden also supports automatic SQLite WAL-mode backups. I run a nightly script that stops the container briefly, copies the data directory to an offsite location, then restarts:

#!/bin/bash
# /opt/scripts/backup-vaultwarden.sh
set -euo pipefail

COMPOSE_DIR="/opt/services/vaultwarden"
BACKUP_DIR="/mnt/backups/vaultwarden"
DATE=$(date +%Y-%m-%d)

mkdir -p "$BACKUP_DIR"

cd "$COMPOSE_DIR"
docker compose exec vaultwarden sqlite3 /data/db.sqlite3 ".backup /data/db-backup.sqlite3"

tar -czf "$BACKUP_DIR/vaultwarden-$DATE.tar.gz" ./vw-data

# Keep 14 days of backups
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +14 -delete

echo "Vaultwarden backup complete: $DATE"

Add it to cron: sudo crontab -e and add 0 3 * * * /opt/scripts/backup-vaultwarden.sh. I also push these backups to a DigitalOcean Space (S3-compatible object storage) using rclone, giving me an offsite copy for roughly $0.02/month at the tiny sizes involved.

Additional Hardening Checklist

Connecting Your Bitwarden Clients

In any official Bitwarden client, look for the "Self-hosted environment" or server URL setting before logging in. Enter https://vault.yourdomain.com as the server URL. Everything else — login, 2FA, vault sync, browser extension — works exactly as it does with bitwarden.com. Your family members can use the same server; just invite them via the admin panel rather than enabling open registration.

Wrapping Up

At this point you have a fully functional, hardened Vaultwarden instance: TLS-terminated by Caddy with strict security headers, admin panel locked behind an SSH tunnel, fail2ban watching for brute-force attempts, and nightly backups running automatically. The total RAM footprint sits at around 15–20 MB — Vaultwarden in Rust is genuinely tiny. I run mine alongside a dozen other services on a single $6 Droplet and barely notice it's there.

Your next steps: set up SMTP so Vaultwarden can send emergency access notifications and 2FA recovery emails, and consider fronting the whole server with a Cloudflare Tunnel if you'd prefer not to expose your server's real IP at all. Both topics have dedicated tutorials here on CompactHost.

If you don't yet have a VPS to host this on, create your DigitalOcean account today and get started — a Basic Droplet is all you need for a personal Vaultwarden instance.

Discussion