LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Vaultwarden: Running Your Own Password Manager

homelabself-hostingsecuritypasswordsvaultwardenbitwardentraefikdocker

Every security professional says the same thing: use a password manager. The argument for self-hosting your password manager is less universally agreed upon, but it’s compelling — your vault lives on your hardware, your credentials never touch a third-party server, and you control the backup and recovery process entirely.

Vaultwarden (formerly Bitwarden_RS) is an unofficial, lightweight Bitwarden-compatible server written in Rust. It implements the full Bitwarden API, meaning every Bitwarden client — browser extensions, desktop apps, the mobile apps — works with it out of the box. The official Bitwarden server requires 2GB+ of RAM and a SQL Server instance; Vaultwarden runs in under 100MB of RAM on a Raspberry Pi.

This guide covers a production-quality Vaultwarden deployment: HTTPS with Traefik, automated encrypted backups, hardened admin configuration, and migrating from other password managers.


Architecture Overview

Internet / LAN
      │
      ▼
  Traefik (reverse proxy + TLS termination)
      │
      ▼
  Vaultwarden (port 80 internally)
      │
  SQLite / PostgreSQL (vault data)
      │
  Backup job → encrypted archive → S3 / Backblaze / local NAS

Vaultwarden uses SQLite by default — perfectly adequate for personal and small-team use. For larger deployments or higher availability requirements, it supports PostgreSQL and MySQL/MariaDB.


Deployment with Docker Compose and Traefik

Directory Structure

vaultwarden/
├── docker-compose.yml
├── .env
└── data/          # Vaultwarden data volume (mounted)
    ├── db.sqlite3
    ├── attachments/
    ├── sends/
    └── config.json

Environment File

1
2
3
4
5
6
7
8
# .env — keep this out of git
DOMAIN=vault.yourdomain.com
ADMIN_TOKEN=   # generate with: openssl rand -base64 48
SMTP_HOST=smtp.gmail.com
SMTP_FROM=vault@yourdomain.com
SMTP_PORT=587
SMTP_USERNAME=vault@yourdomain.com
SMTP_PASSWORD=your-app-password

Generate a strong admin token:

1
2
3
openssl rand -base64 48
# Or argon2 hash (recommended for Vaultwarden 1.28+):
echo -n "your-admin-password" | argon2 $(openssl rand -base64 32) -id -t 3 -m 15 -p 4 -l 32 -e

Docker Compose — Vaultwarden + Traefik

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# docker-compose.yml
services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: unless-stopped
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=admin@yourdomain.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      # Uncomment for staging cert while testing:
      # - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./letsencrypt:/letsencrypt"
    networks:
      - proxy

  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: "https://${DOMAIN}"
      ADMIN_TOKEN: "${ADMIN_TOKEN}"

      # Disable open registration — invite only
      SIGNUPS_ALLOWED: "false"
      INVITATIONS_ALLOWED: "true"

      # Email (optional but highly recommended for 2FA recovery)
      SMTP_HOST: "${SMTP_HOST}"
      SMTP_FROM: "${SMTP_FROM}"
      SMTP_PORT: "${SMTP_PORT}"
      SMTP_SECURITY: "starttls"
      SMTP_USERNAME: "${SMTP_USERNAME}"
      SMTP_PASSWORD: "${SMTP_PASSWORD}"

      # Security settings
      WEBSOCKET_ENABLED: "true"
      PASSWORD_HINTS_ALLOWED: "false"
      SHOW_PASSWORD_HINT: "false"

      # Logging
      LOG_LEVEL: "warn"
      EXTENDED_LOGGING: "false"

      # Data directory
      DATA_FOLDER: "/data"

    volumes:
      - "./data:/data"
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      # Main web UI and API
      - "traefik.http.routers.vaultwarden.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.vaultwarden.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
      - "traefik.http.routers.vaultwarden.service=vaultwarden"
      - "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
      # WebSocket for live sync
      - "traefik.http.routers.vaultwarden-ws.rule=Host(`${DOMAIN}`) && Path(`/notifications/hub`)"
      - "traefik.http.routers.vaultwarden-ws.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden-ws.tls.certresolver=letsencrypt"
      - "traefik.http.routers.vaultwarden-ws.service=vaultwarden-ws"
      - "traefik.http.services.vaultwarden-ws.loadbalancer.server.port=3012"
      # Security headers middleware
      - "traefik.http.routers.vaultwarden.middlewares=vaultwarden-headers"
      - "traefik.http.middlewares.vaultwarden-headers.headers.stsSeconds=31536000"
      - "traefik.http.middlewares.vaultwarden-headers.headers.stsIncludeSubdomains=true"
      - "traefik.http.middlewares.vaultwarden-headers.headers.stsPreload=true"
      - "traefik.http.middlewares.vaultwarden-headers.headers.forceSTSHeader=true"
      - "traefik.http.middlewares.vaultwarden-headers.headers.contentTypeNosniff=true"
      - "traefik.http.middlewares.vaultwarden-headers.headers.browserXssFilter=true"

networks:
  proxy:
    external: false

Internal-Only (LAN) Deployment with Self-Signed Cert

If you only want Vaultwarden accessible on your home network, use Traefik with a self-signed certificate and add the CA to your devices:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Traefik config for LAN-only with self-signed cert
command:
  - "--providers.docker=true"
  - "--entrypoints.websecure.address=:443"
  # Use a local cert instead of ACME
  - "--providers.file.filename=/traefik/tls.yml"

# tls.yml
tls:
  certificates:
    - certFile: /certs/vault.crt
      keyFile: /certs/vault.key
  stores:
    default:
      defaultCertificate:
        certFile: /certs/vault.crt
        keyFile: /certs/vault.key

Or use Caddy instead of Traefik for simpler TLS handling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  caddy:
    image: caddy:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy

# Caddyfile
vault.yourdomain.com {
    reverse_proxy vaultwarden:80
    reverse_proxy /notifications/hub vaultwarden:3012
    encode gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
    }
}

First-Run Setup

1
2
3
4
docker compose up -d

# Check logs
docker compose logs -f vaultwarden

Initial steps:

  1. Navigate to https://vault.yourdomain.com — you’ll see the Bitwarden web vault
  2. Click Create Account to register your admin user (registration is open until you add users, then you can disable it)
  3. After creating your account, go to https://vault.yourdomain.com/admin and log in with your ADMIN_TOKEN

Admin Panel Hardening

In the admin panel (/admin), configure:

General Settings:
  ✓ Allow invitations: true (invite users individually)
  ✗ Allow signups: false (after creating your accounts)
  ✗ Allow password hints: false
  ✓ Require email verification for signups: true

Advanced Settings:
  ✓ Enable websocket notifications: true
  ✓ Domain URL: https://vault.yourdomain.com (must match exactly)

Restrict the admin panel to your IP (highly recommended) via Traefik middleware:

1
2
3
4
# In docker-compose.yml labels for vaultwarden:
- "traefik.http.routers.vaultwarden-admin.rule=Host(`${DOMAIN}`) && PathPrefix(`/admin`)"
- "traefik.http.routers.vaultwarden-admin.middlewares=admin-ipwhitelist"
- "traefik.http.middlewares.admin-ipwhitelist.ipwhitelist.sourcerange=192.168.1.0/24,127.0.0.1/32"

Or completely block /admin from the internet using a separate Traefik router that only matches internal IPs.


Automated Backups

Your password vault backup is one of the most critical backups you have. Losing it means losing access to every account.

What to Back Up

Vaultwarden stores everything in /data:

  • db.sqlite3 — the vault database (all passwords, TOTP secrets, notes)
  • attachments/ — file attachments stored in vault items
  • sends/ — Bitwarden Send files
  • config.json — server configuration
  • rsa_key.* — RSA keys used for encryption (critical — losing these makes the database unreadable)

Backup Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/bin/bash
# /opt/vaultwarden/backup.sh

set -euo pipefail

VAULTWARDEN_DATA="/opt/vaultwarden/data"
BACKUP_DIR="/opt/vaultwarden/backups"
BACKUP_RETENTION_DAYS=30
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="${BACKUP_DIR}/vaultwarden_${TIMESTAMP}.tar.gz.gpg"
GPG_RECIPIENT="your-gpg-key-id"  # or use symmetric: GPG_PASSPHRASE

mkdir -p "${BACKUP_DIR}"

# SQLite backup — use the official backup command to avoid corruption
# from a live database
sqlite3 "${VAULTWARDEN_DATA}/db.sqlite3" ".backup '${BACKUP_DIR}/db_${TIMESTAMP}.sqlite3'"

# Create encrypted archive
tar -czf - \
    -C "${VAULTWARDEN_DATA}" \
    attachments/ \
    sends/ \
    config.json \
    rsa_key.pem \
    rsa_key.pub.pem \
    "${BACKUP_DIR}/db_${TIMESTAMP}.sqlite3" \
  | gpg --batch --yes \
        --recipient "${GPG_RECIPIENT}" \
        --encrypt \
        --output "${BACKUP_FILE}"

# Clean up temp SQLite backup
rm -f "${BACKUP_DIR}/db_${TIMESTAMP}.sqlite3"

# Remove backups older than retention period
find "${BACKUP_DIR}" -name "vaultwarden_*.tar.gz.gpg" -mtime +${BACKUP_RETENTION_DAYS} -delete

echo "Backup complete: ${BACKUP_FILE} ($(du -sh "${BACKUP_FILE}" | cut -f1))"

For symmetric encryption (simpler, no GPG key management):

1
2
3
4
5
6
# Replace the gpg command with:
| gpg --batch --yes \
      --passphrase "${BACKUP_PASSPHRASE}" \
      --symmetric \
      --cipher-algo AES256 \
      --output "${BACKUP_FILE}"

Offsite Upload with rclone

1
2
3
4
5
6
7
8
# Install rclone and configure a remote (Backblaze B2, S3, Wasabi, etc.)
rclone config  # follow interactive setup

# Add to backup script after creating the archive:
rclone copy "${BACKUP_FILE}" "b2:your-bucket/vaultwarden-backups/"

# Verify the upload
rclone ls "b2:your-bucket/vaultwarden-backups/" | tail -5

Cron Schedule

1
2
3
4
5
6
# /etc/cron.d/vaultwarden-backup
# Run at 2 AM daily
0 2 * * * root /opt/vaultwarden/backup.sh >> /var/log/vaultwarden-backup.log 2>&1

# Or weekly full + daily incremental with restic:
# 0 2 * * * root restic -r b2:your-bucket/vaultwarden backup /opt/vaultwarden/data

Backup as a Docker Sidecar

Keep the backup container co-located with Vaultwarden:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Add to docker-compose.yml
  vaultwarden-backup:
    image: bruceforce/vaultwarden-backup:latest
    container_name: vaultwarden-backup
    restart: unless-stopped
    depends_on:
      - vaultwarden
    environment:
      BACKUP_DIR: /backups
      BACKUP_FILE_SUFFIX: "_%Y%m%d"
      CRON_TIME: "0 2 * * *"
      DELETE_AFTER: 30
      RCLONE_REMOTE_NAME: "b2"
      RCLONE_REMOTE_DIR: "your-bucket/vaultwarden"
      TIMESTAMP: "true"
    volumes:
      - ./data:/data:ro          # Vaultwarden data (read-only)
      - ./backups:/backups       # Local backup staging
      - ./rclone.conf:/config/rclone/rclone.conf:ro

Testing Restores

A backup you’ve never tested is not a backup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/bash
# test-restore.sh — run monthly to verify backups are recoverable

BACKUP_FILE="$1"
RESTORE_DIR="/tmp/vaultwarden-restore-test"

mkdir -p "${RESTORE_DIR}"

# Decrypt and extract
gpg --batch --decrypt "${BACKUP_FILE}" | \
    tar -xzf - -C "${RESTORE_DIR}"

# Verify SQLite integrity
sqlite3 "${RESTORE_DIR}/db.sqlite3" "PRAGMA integrity_check;"

# Check RSA keys exist
if [[ -f "${RESTORE_DIR}/rsa_key.pem" && -f "${RESTORE_DIR}/rsa_key.pub.pem" ]]; then
    echo "✓ RSA keys present"
else
    echo "✗ RSA keys MISSING — backup is incomplete!"
    exit 1
fi

# Check database has expected tables
TABLES=$(sqlite3 "${RESTORE_DIR}/db.sqlite3" ".tables")
for table in users ciphers folders collections org_policies; do
    if echo "$TABLES" | grep -q "$table"; then
        echo "✓ Table '$table' present"
    else
        echo "✗ Table '$table' MISSING"
        exit 1
    fi
done

# Count records
USER_COUNT=$(sqlite3 "${RESTORE_DIR}/db.sqlite3" "SELECT COUNT(*) FROM users;")
CIPHER_COUNT=$(sqlite3 "${RESTORE_DIR}/db.sqlite3" "SELECT COUNT(*) FROM ciphers;")
echo "✓ Restore verified: ${USER_COUNT} users, ${CIPHER_COUNT} vault items"

rm -rf "${RESTORE_DIR}"

PostgreSQL Backend (Optional)

For multi-user deployments or if you want ACID-compliant backups via pg_dump:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
services:
  postgres:
    image: postgres:16-alpine
    container_name: vaultwarden-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: vaultwarden
      POSTGRES_PASSWORD: "${DB_PASSWORD}"
      POSTGRES_DB: vaultwarden
    volumes:
      - ./postgres:/var/lib/postgresql/data
    networks:
      - proxy

  vaultwarden:
    image: vaultwarden/server:latest
    environment:
      DATABASE_URL: "postgresql://vaultwarden:${DB_PASSWORD}@postgres/vaultwarden"
      # ... other env vars
    depends_on:
      - postgres

With PostgreSQL, backups become:

1
2
3
# Dump the database
docker exec vaultwarden-db pg_dump -U vaultwarden vaultwarden \
    | gzip | gpg --encrypt --recipient "${GPG_KEY}" > vault_db_$(date +%Y%m%d).sql.gz.gpg

Two-Factor Authentication

Always enable 2FA on your Vaultwarden account — your password manager is the skeleton key to everything else.

Vaultwarden supports:

  • TOTP (Authenticator apps — Aegis, Bitwarden Authenticator, Authy)
  • WebAuthn / FIDO2 (hardware security keys — YubiKey, etc.)
  • Email 2FA (requires SMTP configured)
  • Duo (requires Duo account)
  • YubiKey OTP (requires YubiCloud API key)

To enable in the web vault:

  1. Profile → Account Settings → Two-step Login
  2. Choose your preferred method — TOTP is the best balance of security and usability
  3. Save your recovery code somewhere completely separate from your vault (physical paper, separate encrypted drive)

Enforcing 2FA for an Organization

In the Vaultwarden admin panel or via the Organizations feature:

Organization → Settings → Policies → Require Two-step Login: Enabled

Migrating from Other Password Managers

From LastPass

  1. In LastPass: Account Options → Advanced → Export → LastPass CSV File
  2. In Bitwarden web vault (connected to your Vaultwarden): Tools → Import Data
  3. Select “LastPass (csv)” as format
  4. Upload the file

After importing:

  • Review items marked with [LastPass] tags
  • Delete the CSV export from your computer immediately
  • Check for items with empty usernames or passwords (common LastPass export quirk)
  • Review form fills — LastPass form fill data doesn’t map cleanly to Bitwarden

From 1Password

  1. In 1Password: File → Export → All Items → 1PIF format (1Password Interchange Format)
  2. Or export as CSV for individual vaults
  3. In Bitwarden web vault: Tools → Import → “1Password (1pif)” or “1Password (csv)”

Note on 1Password export format:

1Password’s 1PIF format preserves more data (custom fields, tags, URLs) than CSV. If you have complex vault items with custom fields, use 1PIF. The CSV export loses custom fields.

1
2
# If you have many vaults to export, 1Password CLI makes it easier:
op export --format 1pif > export.1pif

From KeePass/KeePassXC

KeePassXC → Database → Export → CSV

Then import as “KeePass 2 (xml)” or “KeePassX (csv)” in Bitwarden.

Better approach — export as KeePass XML which preserves the folder hierarchy:

KeePassXC → File → Export Database → KeePass 2 XML (*.xml)

From Dashlane

  1. Dashlane → My Account → Export Data → Export to CSV
  2. Import into Bitwarden as “Dashlane (csv)”

Clean up after any migration:

1
2
3
4
# Immediately shred the export file after import
shred -vzu -n 3 ~/Downloads/vault_export.csv
# Or on macOS:
srm -vz ~/Downloads/vault_export.csv

Client Setup

Vaultwarden is compatible with all official Bitwarden clients. After installing, point them at your server:

Browser Extension

  1. Install the Bitwarden extension (Chrome, Firefox, Safari, Edge)
  2. Click the extension → Log in → Self-hosted
  3. Server URL: https://vault.yourdomain.com
  4. Log in with your credentials

Desktop App

  1. Download from bitwarden.com
  2. On the login screen, click the region selector → Self-hosted
  3. Server URL: https://vault.yourdomain.com

Mobile App (iOS/Android)

  1. Download Bitwarden from the App Store / Google Play
  2. Tap the region selector on the login screen → Self-hosted
  3. Server URL: https://vault.yourdomain.com

Bitwarden CLI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Install
npm install -g @bitwarden/cli

# Point at your server
bw config server https://vault.yourdomain.com

# Log in
bw login your@email.com

# Use it
bw list items | jq '.[].name'
bw get password "My Bank"
bw generate -uln --length 32  # generate a strong password

Organizations and Sharing

Vaultwarden supports Bitwarden Organizations for sharing credentials within a household or team.

Web Vault → New Organisation → "Family" (or "Team")
→ Invite family members by email
→ Create Collections: "Shared Streaming", "Home", "Financial"
→ Assign items to collections
→ Set member permissions (View/Edit/Manage per collection)

Organization vaults are end-to-end encrypted — Vaultwarden never sees the plaintext. The encryption key is shared via asymmetric encryption using each member’s RSA key.

Practical household setup:

  • Personal vault: private credentials only you know (work accounts, personal finance)
  • Family organisation: shared streaming passwords, smart home credentials, emergency contacts, home Wi-Fi passwords, router admin credentials

Hardening Checklist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
## Vaultwarden Security Checklist

### Network
- [ ] HTTPS enforced — no HTTP access to vault
- [ ] HSTS header with preload
- [ ] Admin panel restricted to LAN IP range
- [ ] Fail2ban or Traefik rate limiting on login endpoint
- [ ] Port 80 redirects to 443

### Authentication
- [ ] Open registration disabled (SIGNUPS_ALLOWED=false)
- [ ] Admin token is a cryptographically random 48+ char string
- [ ] 2FA enabled on all accounts
- [ ] Emergency access set up (trusted contact for account recovery)

### Data
- [ ] Daily encrypted backups verified working
- [ ] Offsite backup copy (Backblaze B2, S3, etc.)
- [ ] Backup restore tested in the last 90 days
- [ ] RSA keys included in backup

### Monitoring
- [ ] Alerts on backup job failure
- [ ] Alerts on Vaultwarden container restart
- [ ] Log rotation configured

Rate Limiting with Traefik

1
2
3
4
5
# Add to Traefik middlewares in docker-compose.yml
- "traefik.http.middlewares.vaultwarden-ratelimit.ratelimit.average=10"
- "traefik.http.middlewares.vaultwarden-ratelimit.ratelimit.burst=20"
- "traefik.http.middlewares.vaultwarden-ratelimit.ratelimit.period=1m"
- "traefik.http.routers.vaultwarden.middlewares=vaultwarden-headers,vaultwarden-ratelimit"

Fail2ban for Login Brute Force

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>.*$
            ^.*error.*Invalid username or password.*<ADDR>.*$
ignoreregex =

# /etc/fail2ban/jail.d/vaultwarden.conf
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /opt/vaultwarden/data/vaultwarden.log
maxretry = 5
bantime = 3600
findtime = 600

Updating Vaultwarden

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Pull latest image
docker compose pull vaultwarden

# Check release notes at github.com/dani-garcia/vaultwarden/releases
# Look for any database migration notes

# Restart with new image (SQLite migrations run automatically on startup)
docker compose up -d vaultwarden

# Verify it started cleanly
docker compose logs --tail=20 vaultwarden

Before updating: take a manual backup:

1
sqlite3 data/db.sqlite3 ".backup 'data/db_pre_update.sqlite3'"

Monitoring

1
2
3
4
5
6
7
8
# Add a healthcheck to docker-compose.yml
vaultwarden:
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:80/alive"]
    interval: 30s
    timeout: 10s
    retries: 3
    start_period: 15s

Watch for these log patterns:

1
2
3
4
5
6
7
8
# Real-time log monitoring
docker compose logs -f vaultwarden | grep -E "ERROR|WARN|failed"

# Count failed login attempts (last hour)
docker compose logs vaultwarden --since 1h | grep -c "Username or password is incorrect"

# Track who's logging in
docker compose logs vaultwarden --since 24h | grep "Logged in user"

Disaster Recovery

Document your recovery process before you need it:

1
2
3
4
5
6
7
8
9
## Vaultwarden Recovery Runbook

### Scenario: Server dies, need to restore on new host

1. Provision new server with Docker installed
2. Clone deployment repo: `git clone ...`
3. Restore backup:
   ```bash
   gpg --decrypt vaultwarden_YYYYMMDD_HHMMSS.tar.gz.gpg | tar -xzf - -C /opt/vaultwarden/data/
  1. Update DNS to point vault.yourdomain.com at new server IP
  2. docker compose up -d
  3. Verify: curl -f https://vault.yourdomain.com/alive
  4. Log in and confirm vault items are present

Scenario: Corrupted database

  1. Stop Vaultwarden: docker compose stop vaultwarden
  2. Check integrity: sqlite3 data/db.sqlite3 "PRAGMA integrity_check;"
  3. If corrupted, restore from last known good backup
  4. If partially recoverable: sqlite3 data/db.sqlite3 ".recover" | sqlite3 data/db_recovered.sqlite3
  5. Restart: docker compose start vaultwarden

Scenario: Lost admin token

  1. Stop Vaultwarden
  2. Remove ADMIN_TOKEN from environment (or set to empty string)
  3. Restart — admin panel will be disabled entirely
  4. If you need admin access, set a new token and restart

---

## Why Self-Host vs Cloud Bitwarden?

The honest answer: for most people, the paid cloud Bitwarden ($10/year) is the right choice. It has:
- Redundant infrastructure
- Professional security team
- Emergency access features
- No operational overhead

Self-hosting with Vaultwarden makes sense when:
- You want zero third-party dependency for credentials
- You're already running a homelab and the operational overhead is marginal
- You want to learn about self-hosting in a low-stakes environment
- Your organization has compliance requirements about where credential data lives
- You want features from the premium tier without the subscription

The trade-off is real: you are now responsible for availability, backups, and updates. A vault that goes down when you're trying to log into your bank account at the airport is worse than a cloud service. Take the backup and monitoring sections of this guide seriously.

---

*Related: [Securing the Home Lab](/posts/securing-home-lab/), [Secrets Management](/posts/secrets-management/), [Traefik Complete Guide](/posts/traefik-complete-guide/), [Backup Strategy](/posts/backup-strategy/)*

Comments