Vaultwarden is a lightweight, open-source, self-hosted password manager - a Rust reimplementation of the Bitwarden server API. You run the server; you connect with the official Bitwarden phone apps and browser extensions. Your vault never leaves a box you own.

This deploys it behind Traefik, which handles TLS and routing automatically.

docker-compose.yml

# /srv/vaultwarden.example.com/docker-compose.yml
name: vaultwarden-example-com
 
services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vault.example.com
    restart: always
    mem_limit: 256M
 
    environment:
      - SIGNUPS_ALLOWED=false
      - DOMAIN=https://vaultwarden.example.com
    env_file:
      - ./admin.env          # holds ADMIN_TOKEN - see below (keeps the $ safe)
 
    volumes:
      - ./data:/data
 
    labels:
      - com.centurylinklabs.watchtower.enable=true
      - traefik.enable=true
      - traefik.http.routers.vaultwarden.rule=Host(`vaultwarden.example.com`)
      - traefik.http.routers.vaultwarden.entrypoints=https
      - traefik.http.routers.vaultwarden.tls=true
      - traefik.http.routers.vaultwarden.tls.certresolver=lets-encrypt
      # Vaultwarden listens on :80 inside the container - tell Traefik explicitly
      - traefik.http.services.vaultwarden.loadbalancer.server.port=80

Two changes from a bare setup, both intentional:

  • loadbalancer.server.port=80 - with exposedByDefault: false, Traefik needs the port spelled out or the route silently 502s.
  • entrypoints=https - pins the router to the TLS entrypoint (the HTTP one just redirects).

Securing the admin page (the ADMIN_TOKEN)

The admin panel lives at /admin. Never ship ADMIN_TOKEN=TODO_CHANGE_THIS - that’s a full-control backdoor. Vaultwarden accepts a plaintext token, but the recommended form is an Argon2 hash so the secret isn’t stored in plaintext:

# needs the `argon2` CLI (e.g. `apt install argon2`)
echo -n 'a-long-random-admin-password' | argon2 "$(openssl rand -base64 32)" -e -id -k 65540 -t 3 -p 4
# → $argon2id$v=19$m=65540,t=3,p=4$....$....

Put the result in ./admin.env (not inline in environment:):

# admin.env  -  chmod 600, and gitignore it
ADMIN_TOKEN=$argon2id$v=19$m=65540,t=3,p=4$....$....

Gotcha: the hash is full of $. Docker Compose interpolates $ in the environment: block, so an inline token gets mangled - you’d have to double every $ to $$. Using env_file: sidesteps that entirely (its values are passed literally). That’s why the compose above reads from admin.env.

Prefer not to run an admin page at all? Set DISABLE_ADMIN_TOKEN=true only if you also lock /admin some other way (e.g. Traefik Basic Auth or forward-auth).

First run

  1. docker compose up -d, then open https://vaultwarden.example.com.
  2. Because SIGNUPS_ALLOWED=false, you can’t register from the front page - go to /admin, log in with your admin token, and invite your own account.
  3. Create your user, sign in from the Bitwarden app/extension pointed at your DOMAIN, then confirm the invite from /admin.

Keeping signups off means only you (via the admin page) decide who gets an account.

Back it up

Everything lives in ./data - the SQLite database, attachments, and keys. That directory is your vault. Back it up off-box regularly:

# simple nightly copy of the whole data dir
tar czf "vaultwarden-$(date +%F).tgz" ./data

For a hot database, prefer sqlite3 ./data/db.sqlite3 ".backup '/backup/db.sqlite3'" so you copy a consistent snapshot rather than a file mid-write.