Self-Host Vaultwarden on a VPS with Docker Compose

Deploy a hardened Vaultwarden password manager on your VPS. Covers Docker Compose with read-only containers, fail2ban, SMTP for 2FA, backup and restore, and emergency access.

Vaultwarden stores every password you own. A sloppy deployment is worse than no deployment. This tutorial sets up Vaultwarden on a VPS with hardened Docker containers, fail2ban, SMTP for two-factor authentication, and a backup-and-restore procedure.

We assume Docker Engine and a reverse proxy (Nginx or Caddy) are already running on your server. If not, start with Docker in Production on a VPS: What Breaks and How to Fix It and How to Configure Nginx as a Reverse Proxy.

What is Vaultwarden and how does it differ from Bitwarden?

Vaultwarden is a Rust reimplementation of the Bitwarden server API. It runs as a single Docker container using SQLite by default, consumes around 50 MB of RAM at idle, and is fully compatible with all official Bitwarden clients (browser extensions, mobile apps, desktop, CLI). The official Bitwarden self-hosted deployment requires 11+ containers and at least 4 GB of RAM.

Vaultwarden Bitwarden Self-Hosted
Language Rust C# (.NET)
Containers 1 11+
RAM (idle) ~50 MB ~2-4 GB
Database SQLite (default), MySQL, PostgreSQL MSSQL (required)
Bitwarden client support Full Full
SSO (OpenID Connect) Since 1.35.0 Enterprise plan only
Official support Community Bitwarden Inc.
License AGPL-3.0 Proprietary (SSPL for server)

Vaultwarden used to be called bitwarden_rs. If you see that name in older guides, it refers to the same project.

How do I deploy Vaultwarden with Docker Compose on a VPS?

Create a directory structure for Vaultwarden data and configuration. All persistent data lives in a single directory that you will back up later.

mkdir -p /opt/vaultwarden/data
cd /opt/vaultwarden

Generate an argon2-hashed admin token. Never store the admin token in plaintext.

docker run --rm -it vaultwarden/server:1.35.4 /vaultwarden hash

The command prompts for a password twice, then prints an argon2id PHC string:

Generate an Argon2id PHC string using the 'bitwarden' preset:

Password:
Confirm Password:
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$S2mMOA8VnTtIOb3J8Gj9Jw$9cZ0YIKmGxfWEqSMKFMbORkBiW7hMGCls3SXAFXSIVE'

Save that string. You will need it in the environment file.

Create the secrets file with restricted permissions:

touch /opt/vaultwarden/.env
chmod 600 /opt/vaultwarden/.env

Edit /opt/vaultwarden/.env with your values:

DOMAIN=https://vault.example.com
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$your-hash-here'

SIGNUPS_ALLOWED=true
INVITATIONS_ALLOWED=true
EMERGENCY_ACCESS_ALLOWED=true
ORG_CREATION_USERS=all

SHOW_PASSWORD_HINT=false
IP_HEADER=X-Real-IP
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=warn

SMTP_HOST=smtp.example.com
SMTP_FROM=vault@example.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USERNAME=vault@example.com
SMTP_PASSWORD=your-smtp-password

SHOW_PASSWORD_HINT=false prevents leaking hints to anyone who knows a username. IP_HEADER=X-Real-IP tells Vaultwarden to read the client IP from your reverse proxy header, which is needed for fail2ban to ban the right address.

Now create the Compose file.

How do I harden the Vaultwarden Docker container?

The Compose file below pins the image version, drops all Linux capabilities, enables a read-only root filesystem, blocks privilege escalation, and sets memory limits. These are the defaults in this guide, not optional add-ons.

Create /opt/vaultwarden/compose.yaml:

services:
  vaultwarden:
    image: vaultwarden/server:1.35.4
    container_name: vaultwarden
    restart: unless-stopped
    env_file: .env
    user: "1000:1000"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    read_only: true
    tmpfs:
      - /tmp
    volumes:
      - ./data:/data
    ports:
      - "127.0.0.1:8080:80"
    deploy:
      resources:
        limits:
          memory: 512M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/alive"]
      interval: 30s
      timeout: 5s
      retries: 3
  • user: "1000:1000" runs the process as a non-root user inside the container. You need to set ownership on the data directory to match: chown -R 1000:1000 /opt/vaultwarden/data.
  • cap_drop: ALL removes all Linux capabilities. Vaultwarden does not need any because it listens on port 80 inside the container (an unprivileged port in this context since Docker maps it).
  • read_only: true prevents the container from writing anywhere except explicitly mounted volumes and tmpfs. If something exploits Vaultwarden, it cannot write to the container filesystem.
  • ports: "127.0.0.1:8080:80" binds only to localhost. The reverse proxy handles external traffic. Never expose Vaultwarden directly to the internet.
  • deploy.resources.limits.memory: 512M prevents a runaway process from consuming all server RAM. Vaultwarden uses ~50 MB idle and ~100-150 MB under load. 512 MB gives plenty of headroom.

Fix the data directory ownership and start the container:

chown -R 1000:1000 /opt/vaultwarden/data
docker compose up -d
[+] Running 1/1Container vaultwarden  Started

Check the container status:

docker compose ps
NAME          IMAGE                       COMMAND       SERVICE       CREATED          STATUS                    PORTS
vaultwarden   vaultwarden/server:1.35.4   "/start.sh"   vaultwarden   Up 30 seconds (healthy)   127.0.0.1:8080->80/tcp

The (healthy) status means the healthcheck passed. Check the logs for startup errors:

docker compose logs --tail 20

The logs show Vaultwarden starting on port 80 inside the container, with no errors.

Reverse proxy configuration

Your reverse proxy forwards HTTPS traffic to 127.0.0.1:8080. Here is a minimal Nginx server block:

server {
    listen 443 ssl;
    http2 on;
    server_name vault.example.com;

    ssl_certificate /etc/letsencrypt/live/vault.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/vault.example.com/privkey.pem;

    client_max_body_size 525M;
    server_tokens off;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

client_max_body_size 525M allows file attachment uploads up to the Bitwarden limit. server_tokens off hides the Nginx version from responses because version disclosure helps attackers target known vulnerabilities.

Since Vaultwarden 1.29.0, WebSocket traffic is served on the same port as HTTP. No separate /notifications/hub proxy path is needed. If you see WEBSOCKET_ENABLED in older tutorials, ignore it. That variable was deprecated in 1.29.0 and removed in 1.31.0.

For Nginx setup details, see How to Configure Nginx as a Reverse Proxy and Set Up Let's Encrypt SSL/TLS for Nginx on Debian 12 and Ubuntu 24.04.

How do I configure SMTP email for Vaultwarden?

Vaultwarden needs SMTP to send email verification codes, 2FA setup emails, organization invites, and emergency access notifications. Without SMTP, features like invite-based signup, email 2FA, and emergency access do not work.

The key environment variables are SMTP_HOST, SMTP_PORT, SMTP_SECURITY, SMTP_USERNAME, SMTP_PASSWORD, and SMTP_FROM. Set SMTP_PORT=587 with SMTP_SECURITY=starttls for most providers, or SMTP_PORT=465 with SMTP_SECURITY=force_tls for implicit TLS.

These variables were already included in the .env file above. After starting the container, test email delivery by logging into the web vault at https://vault.example.com, creating your account, going to Settings > Security > Two-step Login, and enabling email-based 2FA. If you receive the verification code, SMTP works.

Check the logs if email fails:

docker compose logs | grep -i smtp

Common issues:

  • Authentication failed: double-check SMTP_USERNAME and SMTP_PASSWORD. Special characters in the password may need single quotes in the .env file.
  • Connection refused on port 587: some VPS providers block outbound port 25 and 587 by default. Check with your provider or try port 465 with force_tls.
  • Certificate error: the SMTP server's TLS certificate must be valid. Self-signed certificates require SMTP_ACCEPT_INVALID_CERTS=true (not recommended for production).
Variable Purpose Example
SMTP_HOST Mail server hostname smtp.example.com
SMTP_PORT Connection port 587
SMTP_SECURITY TLS method starttls or force_tls
SMTP_FROM Sender address vault@example.com
SMTP_USERNAME Auth username vault@example.com
SMTP_PASSWORD Auth password (stored in .env, chmod 600)
SMTP_AUTH_MECHANISM Auth type (optional) Login

How do I secure the Vaultwarden admin panel?

The admin panel at /admin lets you manage users, view configuration, and change settings. It is protected by the ADMIN_TOKEN you generated earlier. The argon2 hash means the raw token is never stored in your .env file. Even if someone reads the file, they get a hash, not the password.

After initial setup (creating your account, configuring SMTP, disabling signups), you have two options:

Option 1: Disable the admin panel entirely. Remove or comment out ADMIN_TOKEN from .env and restart:

docker compose down && docker compose up -d

With no ADMIN_TOKEN set, the /admin endpoint returns a 404. This is the safest option for single-user deployments.

Option 2: Restrict access via reverse proxy. Keep the admin panel active but block external access:

location /admin {
    allow 127.0.0.1;
    deny all;

    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Access the admin panel through an SSH tunnel when needed:

ssh -L 8888:127.0.0.1:443 user@your-vps

Then open https://127.0.0.1:8888/admin in your browser.

Disable public registration

Once your account exists, disable open signups. Edit /opt/vaultwarden/.env:

SIGNUPS_ALLOWED=false

Restart the container:

cd /opt/vaultwarden && docker compose down && docker compose up -d

New users can still join through organization invites if INVITATIONS_ALLOWED=true. This lets you add team members without opening registration to the world.

How do I set up fail2ban for Vaultwarden in Docker?

Fail2ban monitors Vaultwarden's log file for failed login attempts and bans offending IPs. Because Vaultwarden runs in Docker, the ban rule must target the DOCKER-USER iptables chain. Standard INPUT chain rules do not affect Docker-routed traffic.

The Vaultwarden container writes logs to /opt/vaultwarden/data/vaultwarden.log (mapped from /data/vaultwarden.log inside the container) because we set LOG_FILE=/data/vaultwarden.log in the environment.

For a detailed fail2ban installation guide, see Install and Configure Fail2Ban on a Linux VPS.

Login filter

Create /etc/fail2ban/filter.d/vaultwarden.local:

[INCLUDES]
before = common.conf

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

Admin panel filter

Create /etc/fail2ban/filter.d/vaultwarden-admin.local:

[INCLUDES]
before = common.conf

[Definition]
failregex = ^.*?Invalid admin token\. IP: <ADDR>.*$
ignoreregex =

Jail configuration

Create /etc/fail2ban/jail.d/vaultwarden.local:

[vaultwarden]
enabled = true
port = http,https
filter = vaultwarden
action = iptables-allports[name=vaultwarden, chain=DOCKER-USER]
logpath = /opt/vaultwarden/data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400
backend = pyinotify

[vaultwarden-admin]
enabled = true
port = http,https
filter = vaultwarden-admin
action = iptables-allports[name=vaultwarden-admin, chain=DOCKER-USER]
logpath = /opt/vaultwarden/data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400
backend = pyinotify
Parameter Value Why
chain DOCKER-USER Docker bypasses the INPUT chain. Rules must go in DOCKER-USER to affect container traffic.
maxretry 3 Three failed attempts trigger a ban. Legitimate users rarely fail more than twice.
bantime 14400 Four-hour ban. Long enough to discourage brute-force, short enough to not permanently lock out a legitimate user who mistyped.
findtime 14400 Counts failures within a four-hour window.
backend pyinotify File-based log monitoring. The default systemd backend cannot read Docker container log files.

Restart fail2ban and check the jail:

systemctl restart fail2ban
fail2ban-client status vaultwarden
Status for the jail: vaultwarden
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- File list:        /opt/vaultwarden/data/vaultwarden.log
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:

Test the regex against the actual log format:

fail2ban-regex /opt/vaultwarden/data/vaultwarden.log /etc/fail2ban/filter.d/vaultwarden.local

If no login failures exist yet, the output shows 0 matched. That is expected. Trigger a fake failed login through the web vault and re-run to confirm the regex matches.

How do I connect browser extensions and mobile apps to Vaultwarden?

All official Bitwarden clients work with Vaultwarden. The only change is pointing them to your server URL instead of bitwarden.com.

Browser extension (Chrome, Firefox, Safari):

  1. Install the Bitwarden extension from your browser's add-on store
  2. On the login screen, click the gear icon (or "Self-hosted" on newer versions)
  3. Set Server URL to https://vault.example.com
  4. Save and log in with your credentials

Mobile app (iOS, Android):

  1. Install the Bitwarden app from the App Store or Google Play
  2. Before logging in, tap the gear icon on the login screen
  3. Set Server URL to https://vault.example.com
  4. Save and log in

Desktop app:

  1. Before logging in, click the gear icon
  2. Set Server URL to https://vault.example.com

CLI:

bw config server https://vault.example.com
bw login

All clients sync through your Vaultwarden instance. Vault data never touches Bitwarden's servers.

How do I set up emergency access in Vaultwarden?

Emergency access lets a trusted contact access or take over your vault if you become unavailable. It requires SMTP to be configured because Vaultwarden sends notification emails during the process.

Confirm the feature is enabled in your .env:

EMERGENCY_ACCESS_ALLOWED=true

Setting up a trusted contact:

  1. Log into the web vault at https://vault.example.com
  2. Go to Settings > Emergency Access
  3. Click Add emergency contact
  4. Enter the email of the trusted person (they must have an account on your Vaultwarden instance)
  5. Choose the access level:
    • View: the contact can view your vault items (read-only)
    • Takeover: the contact can reset your master password and take full control
  6. Set the wait time (1 to 90 days). This is the delay between the contact requesting access and actually getting it. You receive an email notification and can reject the request during this period.
  7. Click Save

The trusted contact receives an invite email. After accepting, they appear as a pending emergency contact. The wait time acts as a dead-man switch: if you do not respond within the configured days, access is granted automatically.

For a personal vault, a 7-day wait with View access is reasonable. For shared team infrastructure, consider shorter wait times.

How do I back up and restore a Vaultwarden instance?

Vaultwarden stores everything in the /data directory: the SQLite database (db.sqlite3), file attachments, icon cache, and configuration. A proper backup captures all of this consistently.

The SQLite database must be backed up with sqlite3 .backup, not by copying the file directly. Copying a live SQLite file can produce a corrupted backup if a write happens during the copy. The .backup command creates a consistent snapshot.

What to back up Location Method Frequency
SQLite database /opt/vaultwarden/data/db.sqlite3 sqlite3 .backup Daily
Attachments /opt/vaultwarden/data/attachments/ rsync or cp -a Daily
Icon cache /opt/vaultwarden/data/icon_cache/ Skip (regenerated automatically) -
Environment file /opt/vaultwarden/.env cp On change
Compose file /opt/vaultwarden/compose.yaml cp On change

Backup script

For automated backups, the script uses age with a recipient key file. This avoids interactive passphrase prompts so the script can run from cron.

Generate a key pair (once):

apt install age
age-keygen -o /opt/vaultwarden/backup-key.txt
chmod 600 /opt/vaultwarden/backup-key.txt

This prints the public key to stdout. Save it to a recipient file:

age-keygen -y /opt/vaultwarden/backup-key.txt > /opt/vaultwarden/backup-key.pub

Store a copy of backup-key.txt (the private key) somewhere off-server. You need it to decrypt backups. If you lose this key, your encrypted backups are unrecoverable.

Create /opt/vaultwarden/backup.sh:

#!/bin/bash
set -euo pipefail

BACKUP_DIR="/opt/vaultwarden/backups"
DATA_DIR="/opt/vaultwarden/data"
DATE=$(date +%Y-%m-%d_%H%M)
BACKUP_FILE="${BACKUP_DIR}/vaultwarden-${DATE}.tar"
RECIPIENT="/opt/vaultwarden/backup-key.pub"

mkdir -p "${BACKUP_DIR}"

# Back up SQLite database consistently
sqlite3 "${DATA_DIR}/db.sqlite3" ".backup '${BACKUP_DIR}/db-${DATE}.sqlite3'"

# Package database + attachments + config
tar cf "${BACKUP_FILE}" \
  -C "${BACKUP_DIR}" "db-${DATE}.sqlite3" \
  -C "${DATA_DIR}" attachments/ \
  -C /opt/vaultwarden .env compose.yaml 2>/dev/null || true

# Encrypt with age recipient key (non-interactive)
age -R "${RECIPIENT}" -o "${BACKUP_FILE}.age" "${BACKUP_FILE}"

# Clean up unencrypted files
rm -f "${BACKUP_FILE}" "${BACKUP_DIR}/db-${DATE}.sqlite3"

# Remove backups older than 30 days
find "${BACKUP_DIR}" -name "*.age" -mtime +30 -delete

echo "Backup complete: ${BACKUP_FILE}.age"
chmod 700 /opt/vaultwarden/backup.sh

For automated daily backups, add a cron job:

echo "0 3 * * * root /opt/vaultwarden/backup.sh >> /var/log/vaultwarden-backup.log 2>&1" > /etc/cron.d/vaultwarden-backup
chmod 644 /etc/cron.d/vaultwarden-backup

For offsite copies, push the encrypted backup to a remote server or object storage:

rsync -az /opt/vaultwarden/backups/*.age backup-user@remote-server:/backups/vaultwarden/

Restore procedure

A backup you have never restored is a backup you cannot trust. The full procedure:

# Stop the container
cd /opt/vaultwarden && docker compose down

# Decrypt the backup
age -d -i /opt/vaultwarden/backup-key.txt -o /tmp/vaultwarden-restore.tar /opt/vaultwarden/backups/vaultwarden-2026-03-20_0300.tar.age

# Extract
mkdir -p /tmp/vaultwarden-restore
tar xf /tmp/vaultwarden-restore.tar -C /tmp/vaultwarden-restore

# Replace the database (delete WAL files first)
rm -f /opt/vaultwarden/data/db.sqlite3-wal /opt/vaultwarden/data/db.sqlite3-shm
cp /tmp/vaultwarden-restore/db-2026-03-20_0300.sqlite3 /opt/vaultwarden/data/db.sqlite3
chown 1000:1000 /opt/vaultwarden/data/db.sqlite3

# Restore attachments
rsync -a /tmp/vaultwarden-restore/attachments/ /opt/vaultwarden/data/attachments/
chown -R 1000:1000 /opt/vaultwarden/data/attachments/

# Start the container
docker compose up -d

# Clean up
rm -rf /tmp/vaultwarden-restore /tmp/vaultwarden-restore.tar

You must delete the WAL (Write-Ahead Log) and SHM files before restoring. These files belong to the old database state. Starting Vaultwarden with a restored database but stale WAL files corrupts the data.

After restore, log into the web vault and confirm your entries are present.

How do I update Vaultwarden safely?

Pin your image to a specific version tag (we used 1.35.4). Never use :latest in production because you cannot track what changed or roll back reliably.

To update:

cd /opt/vaultwarden

# Back up first
./backup.sh

# Pull the new version
docker compose pull

# Recreate the container
docker compose up -d

Before pulling, edit compose.yaml to change the image tag to the new version. Check the release notes for breaking changes.

docker compose logs --tail 30

If the update breaks something, restore from your backup:

# Revert compose.yaml to the old version tag
docker compose down
# Follow the restore procedure above
docker compose up -d

Something went wrong?

Container exits immediately:

docker compose logs

Look for permission errors. The user: 1000:1000 directive requires matching ownership on /opt/vaultwarden/data. Run chown -R 1000:1000 /opt/vaultwarden/data and try again.

Cannot access the web vault:

Check that the reverse proxy is forwarding to 127.0.0.1:8080:

curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/alive

A 200 response means Vaultwarden is running. The problem is in your reverse proxy config.

Fail2ban not banning:

fail2ban-regex /opt/vaultwarden/data/vaultwarden.log /etc/fail2ban/filter.d/vaultwarden.local

If the regex matches zero lines, check that LOG_FILE=/data/vaultwarden.log is set in .env and that the log file exists at /opt/vaultwarden/data/vaultwarden.log.

Emails not sending:

docker compose logs | grep -i "smtp\|mail\|email"

Verify your VPS allows outbound connections on the SMTP port:

nc -zv smtp.example.com 587

Database locked errors:

This can happen if you copy the database file while Vaultwarden is running. Always use sqlite3 .backup for backups and always stop the container before restoring.

Logs location:

# Application logs
docker compose logs -f

# Log file (if LOG_FILE is set)
tail -f /opt/vaultwarden/data/vaultwarden.log

# Fail2ban logs
journalctl -u fail2ban -f

For Docker container security beyond what this tutorial covers, see Docker Security Hardening: Rootless Mode, Seccomp, AppArmor on a VPS. For backup strategies across all your Docker services, see Docker Volume Backup and Restore on a VPS.


Copyright 2026 Virtua.Cloud. All rights reserved. This content is original work by the Virtua.Cloud team. Reproduction, republication, or redistribution without written permission is prohibited.

Ready to try it yourself?

Deploy your own server in seconds. Linux, Windows, or FreeBSD.

See VPS Plans