Skip to content

Vaultwarden

Github GitHub


Overview

Vaultwarden is a lightweight, Rust re-implementation of the Bitwarden server API. The official Bitwarden apps, browser extensions and CLI talk to it as if it were Bitwarden, but it runs as one small container with SQLite instead of Bitwarden's MSSQL-based stack.

It holds every password I have, so it gets treated differently from the rest of the fleet:

  • Pinned version, review-only updates. An unattended breaking or buggy release on the password vault is the last thing you want. The image tag is pinned, Renovate opens a PR for new versions, and Vaultwarden is on the manual-merge list: I read the release notes, then merge.
  • No host port. The reverse proxy reaches the container over a shared Docker network; nothing else can.
  • Signups off. Accounts are created by invite from the admin panel.
  • Admin token as an Argon2 hash, not a plain-text token.
  • Working SMTP, because invites, email verification and new-device alerts all depend on mail.
  • Config in the environment, not the admin panel (see below).

Compose

compose.yaml
services:
  vaultwarden:
    image: vaultwarden/server:1.37.4
    container_name: vaultwarden
    restart: unless-stopped
    env_file:
      - .env
    environment:
      DOMAIN: "https://vault.<YOUR_DOMAIN>"
      SIGNUPS_ALLOWED: "false"
      # Outbound mail (example: Gmail with an app password). SMTP_PASSWORD comes from .env.
      SMTP_HOST: "smtp.gmail.com"
      SMTP_PORT: "587"
      SMTP_SECURITY: "starttls"
      SMTP_FROM: "<YOUR_EMAIL>"
      SMTP_FROM_NAME: "Vaultwarden"
      SMTP_USERNAME: "<YOUR_EMAIL>"
      # ADMIN_TOKEN comes from .env as an Argon2id PHC string.
      # Keep config here, not in the admin panel: saving there writes data/config.json, which overrides env.
    # No host port: the reverse proxy reaches vaultwarden:80 over the shared proxy network.
    networks:
      - tunnel-net
    security_opt:
      - no-new-privileges:true
    volumes:
      - /mnt/appdata/vaultwarden/data:/data
    # The image ships its own HEALTHCHECK (curls /alive), so none is declared here.

networks:
  tunnel-net:
    external: true   # created once with: docker network create tunnel-net
.env.example
# Argon2id PHC string from `vaultwarden hash` (see below). Single quotes: no $ escaping needed.
ADMIN_TOKEN='<YOUR_ARGON2_PHC_STRING>'
# App password from your mail provider (not your account password)
SMTP_PASSWORD=<YOUR_SMTP_APP_PASSWORD>

tunnel-net is the network the reverse proxy (and, in my case, the Cloudflare tunnel's proxy hop) shares with the apps it fronts. Because the container is only on that network, vaultwarden:80 is reachable from the proxy and from nowhere else, not even the host's LAN address. Uptime monitoring then watches the container's health status instead of an HTTP port.

Admin token as an Argon2 hash

A plain ADMIN_TOKEN is a password sitting in an env file. Vaultwarden can check a hash instead:

docker exec -it vaultwarden /vaultwarden hash

It asks for the password twice and prints a PHC string ($argon2id$v=19$...). Put that string in .env wrapped in single quotes (as above). If you put it directly in the compose file's environment: block instead, every $ has to be doubled to $$, or Compose tries to interpolate it. Run docker compose config to check what the container will actually get.

Log in to /admin with the password, not the hash.

Don't click Save in the admin panel

The first time you save anything in /admin, Vaultwarden writes data/config.json, and values in that file override the environment. From then on, changing compose.yaml silently does nothing for those keys. Use the admin panel to look and test (e.g. SMTP Email Settings > Send test email works without saving), and keep the real config in compose/.env where it's version-controlled.

SMTP

Without mail, an invite "succeeds" but nobody receives it, and verification and new-device emails never arrive. Any SMTP provider works. With Gmail:

  1. Turn on 2-Step Verification on the account.
  2. Create an app password at myaccount.google.com/apppasswords.
  3. Put it in .env as SMTP_PASSWORD, set the SMTP_* block as above, redeploy.
  4. Admin panel → SMTP Email Settings → Send test email (don't Save).

Don't deploy the SMTP_* settings before the password exists: with SMTP configured but unable to log in, invite and verification flows fail outright instead of just skipping the mail.

First setup

  1. Deploy with SIGNUPS_ALLOWED=false from the start: nobody, including you, signs up through the public form.
  2. Open https://vault.<YOUR_DOMAIN>/admin, log in with the admin password, Users > Invite User with your address.
  3. Accept the invite from the email, create the account and a strong master password, then turn on two-step login (TOTP or a passkey/security key) in the web vault.
  4. Import your old password manager's export (Tools > Import data), then delete the export file.
  5. In the Bitwarden apps/extensions, choose Self-hosted and enter your server URL.

Clients need to reach it

The mobile apps and browser extensions talk to the server directly. If you put the vault behind an SSO gateway (Cloudflare Access, Authelia, ...), the apps can't complete that login; either give the vault hostname its own policy (Vaultwarden has its own auth + 2FA) or only use it on the LAN/VPN.

Backups

/data holds the SQLite database, attachments, sends and the RSA keys. Back up the whole directory, and dump the database with SQLite's own .backup (borgmatic has a sqlite_databases hook for this) rather than copying a live db.sqlite3 mid-write. Test a restore at least once: a password vault you can't restore is worse than none.

Sources & further reading