Deploying Vaultwarden with Docker & Caddy: Hardened Self-Hosted Password Management

4 min read

Password managers are critical personal and organizational infrastructure. While commercial password services introduce third-party risk, deploying the upstream Bitwarden stack requires gigabytes of RAM across a dozen enterprise Java and MS SQL containers. In contrast, Vaultwarden—a lightweight, Bitwarden-compatible API written in Rust—operates comfortably in less than 50MB of RAM.

This runbook details the production deployment of Vaultwarden on a Linux VPS using Docker Compose, automated TLS termination via Caddy, native WebSocket live-sync notifications, security hardening (disabling open registrations, Argon2 password hashing), and atomic SQLite pre-backup routines.


1. Architecture & Security Prerequisites

  • HTTPS Enforcement: Modern web browsers enforce the WebCrypto API. Browser extensions and mobile apps will refuse to decrypt or synchronize vaults over plain HTTP. An automated reverse proxy (Caddy or Cloudflare Tunnel) is strictly required.
  • WebSocket Notifications: Vaultwarden uses WebSockets (on port 3012 or integrated via Rocket) so that changes made on one client instantly synchronize across other devices without polling.
  • Signups Disabled: Never leave a password manager open to public user registrations on a public VPS. Once your primary account is created, disable registration immediately.

2. Production Docker Compose Configuration

Create /opt/komodo/stacks/vaultwarden/compose.yaml:

services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      - WEBSOCKET_ENABLED=true
      - SIGNUPS_ALLOWED=false            # Lock down registration after creating admin
      - INVITATIONS_ALLOWED=false
      - SHOW_PASSWORD_HINT=false
      - DOMAIN=https://vault.example.com
      - ADMIN_TOKEN=${ADMIN_TOKEN}      # Generated with argon2 hashing
    volumes:
      - ./data:/data
    networks:
      - web_net
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          memory: 256M

networks:
  web_net:
    external: true

3. Reverse Proxy Configuration with Caddy

Caddy automatically obtains and renews Let’s Encrypt / ZeroSSL TLS certificates and seamlessly handles HTTP/2, HTTP/3, and WebSocket proxying without complex header rewriting.

In your /opt/komodo/stacks/0.proxy/Caddyfile:

vault.example.com {
    encode gzip zstd

    # Route all traffic to Vaultwarden on internal Docker network
    reverse_proxy vaultwarden:80 {
        header_up X-Real-IP {remote_host}
    }
}

4. Admin Token Generation (Argon2id)

Protect the Vaultwarden administration interface (/admin) using an Argon2id password hash rather than a plain text secret:

# Generate secure Argon2id hash using Docker
docker run --rm -it vaultwarden/server:latest /vaultwarden hash --preset owasp

# Output:
# ADMIN_TOKEN='$argon2id$v=19$m=65536,t=3,p=4$...'

Store this hash in your /opt/komodo/stacks/vaultwarden/.env file:

ADMIN_TOKEN='$argon2id$v=19$m=65536,t=3,p=4$...'

5. Atomic Database Backup Strategy

Vaultwarden uses SQLite with Write-Ahead Logging (WAL) enabled by default. Backing up a live db.sqlite3 file directly can result in corrupted backups if a write transaction is in progress. Always use the SQLite native backup API to create an atomic, crash-consistent snapshot before running Restic backups:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/opt/backups/vaultwarden"
DATA_DIR="/opt/komodo/stacks/vaultwarden/data"
mkdir -p "$BACKUP_DIR"

# Perform atomic SQLite snapshot
docker exec vaultwarden sqlite3 /data/db.sqlite3 ".backup '/data/db_backup.sqlite3'"

# Move snapshot to backup directory
mv "$DATA_DIR/db_backup.sqlite3" "$BACKUP_DIR/vaultwarden-$(date +%F_%H%M%S).sqlite3"

# Keep last 7 days of daily database snapshots
find "$BACKUP_DIR" -name "*.sqlite3" -mtime +7 -delete

Automating Stack Deployment with Ansible

You can deploy Vaultwarden and its atomic backup routines declaratively using the community.docker.docker_compose_v2 module:

# playbook-vaultwarden.yml
---
- name: Deploy Vaultwarden Stack
  hosts: vps
  become: true
  tasks:
    - name: Ensure Vaultwarden directories exist
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        owner: "{{ new_user }}"
        group: "{{ new_user }}"
        mode: "0750"
      loop:
        - /opt/komodo/stacks/vaultwarden
        - /opt/komodo/stacks/vaultwarden/data
        - /opt/backups/vaultwarden

    - name: Deploy Vaultwarden compose file
      ansible.builtin.copy:
        src: compose.yaml
        dest: /opt/komodo/stacks/vaultwarden/compose.yaml
        owner: "{{ new_user }}"
        group: "{{ new_user }}"
        mode: "0640"

    - name: Start Vaultwarden container stack
      community.docker.docker_compose_v2:
        project_src: /opt/komodo/stacks/vaultwarden
        state: present

Related Production Runbooks

Tags:

← Previous:
Next: →