Storage choice: SQLite or PostgreSQL
Vaultwarden supports SQLite (default), PostgreSQL, and MySQL/MariaDB. For one user or a small family, SQLite is fine — the database is a single file, backups are trivial, and the access pattern is mostly tiny reads. For a team of more than a handful, or any high-availability setup, PostgreSQL is the right pick. This walkthrough uses SQLite for simplicity; the PostgreSQL difference is a single environment variable at the end.
Install via Podman or Docker
Create a directory for persistent data, then run the container. Quadlet form (see the Podman/Quadlet tutorial for the full pattern):
# ~/.config/containers/systemd/vaultwarden.container
[Unit]
Description=Vaultwarden
After=network-online.target
[Container]
Image=docker.io/vaultwarden/server:latest
ContainerName=vaultwarden
PublishPort=127.0.0.1:8080:80
Volume=%h/vaultwarden/data:/data:Z
Environment=DOMAIN=https://vault.example.com
Environment=SIGNUPS_ALLOWED=true
Environment=ADMIN_TOKEN=<long-random-string>
[Service]
Restart=always
[Install]
WantedBy=default.target
Plain docker-compose equivalent:
version: "3.9"
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./data:/data
environment:
DOMAIN: https://vault.example.com
SIGNUPS_ALLOWED: "true"
ADMIN_TOKEN: <long-random-string>
Generate the admin token with the Argon2-hashed form — plain-text admin tokens in env vars are explicitly discouraged in recent Vaultwarden:
docker run --rm vaultwarden/server /vaultwarden hash
# Enter a long password when prompted; copy the printed $argon2id$... hash
# into ADMIN_TOKEN.
Reverse proxy with TLS
Vaultwarden refuses to serve clients unless the connection is HTTPS — the client-side encryption assumes it. Caddy:
vault.example.com {
reverse_proxy 127.0.0.1:8080
# Vaultwarden has a separate WebSocket endpoint for live sync
@ws path /notifications/hub
handle @ws {
reverse_proxy 127.0.0.1:8080 {
transport http {
read_timeout 1h
write_timeout 1h
}
}
}
}
nginx equivalent (the WebSocket location block matters):
server {
listen 443 ssl http2;
server_name vault.example.com;
# ... ssl_certificate, ssl_certificate_key ...
client_max_body_size 525M; # for attachments
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;
}
location /notifications/hub {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
}
SMTP for invites and password reset
Without email, organizations cannot send invitations, and there's no password-hint email. Configure SMTP via environment:
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_FROM=vault@example.com
SMTP_FROM_NAME=Vault
SMTP_USERNAME=vault@example.com
SMTP_PASSWORD=...
Test from the admin panel → SMTP settings → "Send test email."
The admin panel
Visit https://vault.example.com/admin and enter the plaintext admin password (the one matching the $argon2id$ hash you set as ADMIN_TOKEN). The panel gives:
- User management (resetting MFA, deleting accounts, disabling accounts)
- Org management
- Live config editor for env vars (changes persist to
data/config.jsonand take effect without a restart for most settings) - SMTP test send
- Backup hint (it's just
data/db.sqlite3+data/attachments/)
Hardening after first user is created
Two changes once your account exists:
- Set
SIGNUPS_ALLOWED=false(admin panel → General Settings) — further accounts go via emailed invite only. Without this, your/api/accounts/registerendpoint is open to the internet. - Set
SHOW_PASSWORD_HINT=false— otherwise the API will return the password hint for any registered email, which is a username-enumeration channel.
Enable MFA on every account from a client (Settings → Two-step Login — TOTP, FIDO2/WebAuthn, or Email).
fail2ban / CrowdSec
Vaultwarden logs failed admin/login attempts; a fail2ban jail or a CrowdSec scenario blocks repeated failures at the firewall. Minimal fail2ban filter:
# /etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\..*$
^.*Invalid admin token\. IP: <ADDR>.*$
ignoreregex =
# /etc/fail2ban/jail.d/vaultwarden.conf
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /home/youruser/vaultwarden/data/vaultwarden.log
maxretry = 5
findtime = 5m
bantime = 1h
Set LOG_FILE=/data/vaultwarden.log as a container env var so the logs land at a stable path.
Backups
Three things to capture:
data/db.sqlite3— the entire vault, encrypted at rest with each user's account key. Usesqlite3 data/db.sqlite3 ".backup /backup/$(date +%F).sqlite3"to get a consistent copy without quiescing.data/attachments/— user-uploaded files attached to vault items.data/sends/,data/rsa_key.*— Send feature state and the JWT signing keys.
The whole data/ directory packaged into a nightly restic backup (see restic + S3) is sufficient. Even if the storage backend is breached, the vault content is still client-side encrypted — an attacker has to also know each user's master password to decrypt.
PostgreSQL variant
Swap SQLite for Postgres by setting one env var:
DATABASE_URL=postgresql://vw_user:<password>@postgres-host:5432/vaultwarden
The schema is migrated automatically on first start. Backups become pg_dump instead of file copy; everything else is identical.
Migrating from the official Bitwarden
The data formats are the same. Export from the official client (Settings → Tools → Export vault → .json, encrypted), point the same client at the Vaultwarden URL, create a new account with the same master password, and import the .json. No conversion step.