Vaultwarden — lab password store, registration locked

Projects · Guides

Install a lab Vaultwarden

Done: Vaultwarden answers on vault.example.lab, /alive returns a timestamp, and new signups are refused.

Requirements

Vaultwarden speaks the Bitwarden API. It holds passwords, API tokens, SSH keys, and notes. Put it on the lab LAN. Terminate TLS on the vault host. This is lab. It is not a production secrets design.

Change the example names. Do not use them as live names.

Example names
Name Change it Role
vault.example.lab yes HTTPS name clients use. TLS checks this name
/opt/vaultwarden yes Stack directory on the vault guest
./vw-data path stays relative The vault. Snapshot this directory. Do not print its contents
Hardware, OS, and packages
Item Need
Guest A Linux guest on the lab LAN. Docker Engine and the Compose plugin
Hostname vault.example.lab in /etc/hostname and in lab DNS. Clients use the name. A bare address fails the TLS check
Disk Room for vw-data (SQLite, attachments, config). The data directory is the vault
TLS A certificate for vault.example.lab on this guest. Do not publish the admin page to the internet
Break-glass Vault password and admin token copied offline before you depend on the vault. If the vault is down, you cannot ask the vault for the vault password
Ports
Service Port Protocol Notes
Vaultwarden (container) 80 TCP Compose network only. Do not publish this port
Caddy / web vault 443 TCP Lab hostname HTTPS. Clients hit this
Admin — — Do not publish /admin off the lab LAN
Environment flags
Variable First boot After the first account
DOMAIN https://vault.example.lab unchanged
SIGNUPS_ALLOWED true false
INVITATIONS_ALLOWED true false
ADMIN_TOKEN generate locally; put it in the env file unchanged; never in this page

Procedure

1. Name the host and the disk

Clients check the TLS name. Set the hostname and the stack directory before compose.

  1. On the vault guest, as root, set the name and the directory:
    # hostnamectl hostname vault.example.lab
    # hostname
    # expect: vault.example.lab
    
    # install -d -m 755 /opt/vaultwarden
    # cd /opt/vaultwarden
    
  2. Generate the admin token. Put the value in .env later. Keep a copy offline. Do not print it here.
    # umask 077
    # openssl rand -base64 48 > .admin-token
    # chmod 600 .admin-token
    # test -s .admin-token && echo admin-token-present
    # expect: admin-token-present
    
  3. Point lab DNS at this guest for vault.example.lab. This page does not print the address. See the Pi-hole guide for the fleet DNS record. Clients use the name. A bare address fails the TLS check.

2. Write compose and Caddy

Compose plus Caddy on the same guest is the lab path. TLS terminates here.

  1. Stay in /opt/vaultwarden. Write .env. Paste the admin token from .admin-token onto the ADMIN_TOKEN line. Leave signups on for the first account.
    # cd /opt/vaultwarden
    # cat > .env <<'EOF'
    DOMAIN=https://vault.example.lab
    SIGNUPS_ALLOWED=true
    INVITATIONS_ALLOWED=true
    # ADMIN_TOKEN=<paste from .admin-token, then chmod 600 .env>
    EOF
    # chmod 600 .env
    
  2. Write compose.yml. Do not put the admin token in this file. Load it from .env. Do not publish container port 80.
    # compose.yml
    services:
      vaultwarden:
        image: vaultwarden/server:latest
        container_name: vaultwarden
        restart: unless-stopped
        env_file:
          - .env
        environment:
          DOMAIN: "https://vault.example.lab"
          SIGNUPS_ALLOWED: "true"
          INVITATIONS_ALLOWED: "true"
        volumes:
          - ./vw-data:/data
    
      caddy:
        image: caddy:2
        container_name: vaultwarden-caddy
        restart: unless-stopped
        depends_on:
          - vaultwarden
        ports:
          - "443:443"
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - ./caddy-data:/data
          - ./caddy-config:/config
          - ./certs:/etc/caddy/certs:ro
    
  3. Write the Caddyfile. Substitute vault.example.lab.
    # Caddyfile — TLS on this guest. Put the lab cert under ./certs/
    vault.example.lab {
      tls /etc/caddy/certs/vault.example.lab.pem /etc/caddy/certs/vault.example.lab-key.pem
      reverse_proxy vaultwarden:80 {
        header_up X-Real-IP {remote_host}
      }
    }
    
  4. Copy the certificate and key for vault.example.lab into ./certs/. This page does not mint the cert. Use the same CA your other lab names use.
    # install -d -m 700 /opt/vaultwarden/certs
    # install -m 644 /path/to/vault.example.lab.pem /opt/vaultwarden/certs/vault.example.lab.pem
    # install -m 600 /path/to/vault.example.lab-key.pem /opt/vaultwarden/certs/vault.example.lab-key.pem
    # ls -l /opt/vaultwarden/certs
    # expect: pem and key present; key mode 600
    

3. Start the stack and create the first account

The first account is the bootstrap. Create it while signups are still on.

  1. Confirm Compose v2 is on the guest, then start the stack:
    # docker compose version
    # expect: Docker Compose version v2.x
    
    # cd /opt/vaultwarden
    # docker compose up -d
    # docker compose ps
    # expect: vaultwarden Up, vaultwarden-caddy Up
    
  2. Open https://vault.example.lab. Expect the web vault, not a TLS name mismatch.
  3. Click Create account. Enter an email and a master password. Submit. Do not put that password in this page. Keep a break-glass copy offline.
  4. Sign in if the page sends you back to login. Confirm the vault opens.
  5. The picture below is the login page from a disposable sandbox, not the lab vault.
Vaultwarden login page from a disposable local sandbox; password field empty; no account created
Step 3 — Login page of a disposable Vaultwarden sandbox on this machine. Password field empty. This is not the lab vault, and no account was created.

4. Lock registration

Anyone who can reach the name can create an account until you turn signups off.

  1. Open .env and the environment: block in compose.yml. Change only these two lines. Leave DOMAIN and ADMIN_TOKEN alone.
    SIGNUPS_ALLOWED=false
    INVITATIONS_ALLOWED=false
    
  2. Recreate the vault container so it reads the new flags:
    # cd /opt/vaultwarden
    # docker compose up -d --force-recreate vaultwarden
    # docker compose ps
    # expect: vaultwarden Up, vaultwarden-caddy Up
    
  3. Prove a new signup is refused:
    # curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
      https://vault.example.lab/identity/accounts/register
    # expect: 400
    
  4. The admin panel uses a separate token from the day-to-day account. It lives in the env file, not in this guide.

5. Snapshot before you change it

The data directory is the vault. Snapshot the guest before a rebuild or a version jump.

  1. Confirm the data directory exists after the first login:
    # ls -ld /opt/vaultwarden/vw-data
    # expect: a directory, not empty
    
  2. Put this guest on the lab backup store. See the PBS guide. A restore you have booted is the prove. A green backup-job line is a wish.
  3. Do not practice restore against the live data directory. Do not restore over the live vault VMID.

Verification

$ curl -fsS https://vault.example.lab/alive
# expect: a timestamp, nothing else
# example: "2026-10-04T15:00:00.000000000Z"

$ curl -sS -o /dev/null -w '%{http_code}\n' https://vault.example.lab/
# expect: 200

$ curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
  https://vault.example.lab/identity/accounts/register
# expect: 400 (registration not allowed)

Done when /alive returns a timestamp, the web vault loads on the lab name, and a new signup is refused.

The stack writes logs with docker compose logs vaultwarden from /opt/vaultwarden. The vault itself is ./vw-data.

Next

Put this guest on the PBS daily job before a version jump. Prove restore on a scratch copy, not on the live data directory. See the PBS guide.

Lab only · Vaultwarden · Voice: OWNER-VOICE.md · GUIDE-DESIGN-LAW recut 2026-10-04 · figure: disposable sandbox login, not the lab vault