← Start of tutorial

WIUNIX ACADEMY

Self-host Vaultwarden with HTTPS, restricted admin access and consistent backups

Page 2 of 5. Review the earlier steps and prerequisites before continuing.

YOUR SYSTEM. YOUR STEPS.

Where are you working?

Choose your OS and version. The prerequisites, commands and troubleshooting follow your environment.

Debian12Reviewed against official documentation ·

USE YOUR OWN VALUES

Configure this tutorial

Set the example values for your environment; related explanations and copy panels update together. Never enter passwords, private keys or tokens. Values stay in this tab and are not sent to the server or share links. Check actual DNS, certificates and network policy before running commands.

Different IP addresses, domains or ports? Open the settings below, change the values and select “Apply to tutorial”, then copy the updated commands.

Set values for my environment7 editable values
03

LET’S BUILD

Create a new private storage tree

The operation refuses an existing root and unsafe/symlinked parents. Data is owned by numeric 23159; configuration, TLS, backups and the secret stay root-controlled. No host login account is created. Reserve the identity in inventory.

bash

Dedicated Vaultwarden VM

sudo python3 - <<'PY'
import os,stat
from pathlib import Path
r=Path('/srv/academy-vaultwarden')
assert r.is_absolute() and '..' not in r.parts and str(r) not in ('/','/srv','/opt','/etc')
for p in reversed(r.parents):
    assert stat.S_ISDIR(p.lstat().st_mode) and not p.is_symlink(), 'Unsafe/missing parent'
    assert p.stat().st_uid==0 and not p.stat().st_mode & 0o022, 'Parent must be root-controlled'
r.mkdir(mode=0o750)
os.chown(r,0,23159);os.chmod(r,0o750)
for name in ('data','config','secrets','tls','backups'):
    p=r/name;p.mkdir(mode=0o700)
    os.chown(p,23159 if name=='data' else 0,23159)
    os.chmod(p,0o750 if name=='secrets' else 0o700)
for name in ('settings.env','nginx.conf'):
    fd=os.open(r/'config'/name,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
    os.close(fd)
print('New private tree created; existing paths are never replaced')
PY
How do I know it worked?

Only the new tree /srv/academy-vaultwarden exists with restricted ownership/modes.

04

LET’S BUILD

Hash the admin passphrase without printing it

Use a trusted private terminal with recording disabled. The hidden prompt reads a new unique ≥24-character admin passphrase twice; only its Argon2id PHC is written to the private file. Keep the original passphrase in a separate approved secret store, never in shell history, this form, source JSON, Git, tickets or screenshots. The hash is also sensitive and enables offline guessing.

The helper sends the passphrase to argon2 through stdin, not argv/environment, captures its output in memory and writes with exclusive/no-follow flags. Parameters are64MiB,3 iterations,4 lanes. This protects the admin login only; it does not set a user’s master-password KDF.

bash

Dedicated Vaultwarden VM

sudo python3 - <<'PY'
import getpass,os,re,secrets,subprocess
from pathlib import Path
p=Path('/srv/academy-vaultwarden/secrets/admin.phc')
assert not p.exists() and not p.is_symlink(), 'Secret already exists'
with open('/dev/tty','r+') as tty:
    password=getpass.getpass('New admin passphrase: ',stream=tty)
    again=getpass.getpass('Repeat admin passphrase: ',stream=tty)
assert password==again and len(password)>=24, 'Mismatch or too short'
result=subprocess.run(['argon2',secrets.token_hex(16),'-id','-m','16','-t','3','-p','4','-e'],input=password.encode(),stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=True)
phc=result.stdout.decode().strip()
assert re.fullmatch(r'\$argon2id\$v=19\$m=65536,t=3,p=4\$[A-Za-z0-9+/]+\$[A-Za-z0-9+/]+',phc), 'Unexpected hash format'
fd=os.open(p,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o640)
with os.fdopen(fd,'w') as f: f.write(phc+'\n')
os.chown(p,0,23159);os.chmod(p,0o640)
del password,again,phc,result
print('Protected Argon2id file created; no secret was printed')
PY
How do I know it worked?

Protected0640 admin PHC file exists; no password/hash output.

05

LET’S BUILD

Open the protected configuration editor

Open the precreated file, replace its empty content with the next panel, save and exit. Keep the admin secret out of this file: ADMIN_TOKEN_FILE references the separate raw PHC. Do not enable DISABLE_ADMIN_TOKEN. Existing config.json overrides environment settings; a new data directory must not contain one.

bash

Dedicated Vaultwarden VM

sudo env HOME=/root nano --ignorercfiles --restricted --noread -- '/srv/academy-vaultwarden/config/settings.env'
How do I know it worked?

Settings file contains the next reviewed nonsecret values.

Service environment file

SIGNUPS_ALLOWED=false closes open registration. INVITATIONS_ALLOWED=false blocks ordinary organization invitations, not server-admin invitations. ORG_CREATION_USERS=none reserves organization creation for an explicit later decision. Sends are disabled. The admin allowlist is applied at Nginx, in addition to the passphrase.

ini · /srv/academy-vaultwarden/config/settings.env
DOMAIN=https://vault.example.com
SIGNUPS_ALLOWED=false
INVITATIONS_ALLOWED=false
ORG_CREATION_USERS=none
SENDS_ALLOWED=false
ADMIN_TOKEN_FILE=/run/secrets/admin.phc
ADMIN_SESSION_LIFETIME=10
IP_HEADER=X-Real-IP
IP_HEADER_TRUSTED_PROXIES=local
ROCKET_ADDRESS=0.0.0.0
ROCKET_PORT=8000
DATA_FOLDER=/data
LOG_LEVEL=warn

Keep PHC dollars literal

The mounted admin.phc contains exactly one raw PHC line, without quotes or doubled dollars. ADMIN_TOKEN_FILE loads that file directly; neither the shell nor Compose interpolates its content. Do not print the file or run environment dumps to diagnose it.

If you later move the secret into a Compose inline environment value, every literal dollar must be doubled there. Compose .env values require their own quoting rules. Docker --env-file does not use those same quoting semantics. These are different parsers; do not copy escaped or quoted PHC into the raw secret file. Prefer keeping this file-based design and review the official configuration/admin documentation.

06

LET’S BUILD

Start the pinned loopback service

Run after saving settings. The container uses nonroot 23159, a read-only root filesystem and a writable private data mount. Docker administrators can still read mounted secrets: access to Docker is privileged. Only127.0.0.1:8159 is published; do not replace it with0.0.0.0. A restart policy is not a reboot acceptance test.

bash

Dedicated Vaultwarden VM

set -eu
sudo docker pull 'docker.io/vaultwarden/server:1.37.4@sha256:efb3cde962015fcc036b2ea625242248611943b27212ebf4392841fb30fad055'
sudo docker run -d --name 'academy_vaultwarden' --restart unless-stopped \
  --user '23159:23159' --cap-drop ALL \
  --security-opt no-new-privileges:true --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --env-file '/srv/academy-vaultwarden/config/settings.env' \
  --mount 'type=bind,src=/srv/academy-vaultwarden/data,dst=/data' \
  --mount 'type=bind,src=/srv/academy-vaultwarden/secrets/admin.phc,dst=/run/secrets/admin.phc,readonly' \
  -p '127.0.0.1:8159:8000' \
  --entrypoint /vaultwarden 'docker.io/vaultwarden/server:1.37.4@sha256:efb3cde962015fcc036b2ea625242248611943b27212ebf4392841fb30fad055'
ready=no
for attempt in $(seq 1 30); do
  status=$(curl --silent --max-time 2 --output /dev/null --write-out '%{http_code}' 'http://127.0.0.1:8159/alive' || true)
  if test "$status" = 200; then ready=yes; break; fi
  sleep 1
done
test "$ready" = yes
sudo ss -lntp 'sport = :8159'
How do I know it worked?

The /alive probe reaches200 and ss shows only loopback 8159.

Explore all tutorials ↗