Homelab
Proof

U4 / Apps under test

Vaultwarden behind Caddy: RAM, sync times and hardening

Tested on Vaultwarden 1.37.4

Test result

Vaultwarden 1.37.4 plus Caddy used 19.4 MiB idle and 52.0 MiB after 2,000 items were added, with a sampled peak of 117 MiB under load. A full sync of 2,000 items took a median of 102 ms on loopback (68 to 118 ms over 3 runs; an earlier run of the same script gave 70 ms). With SIGNUPS_ALLOWED=false (the compose default here) a new signup was refused with HTTP 400, with no ADMIN_TOKEN the admin page was disabled, and an argon2-hashed ADMIN_TOKEN worked through Compose when every $ was written as $$.

Line drawing: a heavy small bank-vault door set into a small server unit, with a dial in the middle.

A personal Vaultwarden fits in very little memory, so the sizing question is about TLS and hardening, not RAM. This test ran Vaultwarden 1.37.4 behind Caddy with its internal certificate authority, filled one account with 2,000 synthetic logins through the API, and measured what a client sees.

HTTPS is part of the setup

The Vaultwarden wiki says that enabling HTTPS is “pretty much required nowadays” because the Bitwarden web vault uses web crypto APIs that most browsers expose only in HTTPS contexts. The same page says the mobile app needs properly configured OCSP stapling. This test did not try a mobile client or a browser over plain HTTP on a non-local name, so those two statements are the documentation’s, not measured here.

What was measured: Caddy issued a certificate for vault.lab.internal from its own local authority (“Caddy Local Authority - 2026 ECC Root”, valid to 2036) and served it over TLS 1.3 with HTTP/2. A plain HTTP request to the TLS port got HTTP 400 from Caddy. The web vault (version 2026.7.0, read from /web-vault/version.json in the container) loaded and unlocked in Chromium once the test browser was told to ignore the lab CA, and the API client trusted the exported root certificate. Clients on real devices would need that root installed, or a certificate from a public CA.

lab/tests/vaultwarden/compose.yml
Show the full file
services:
  vaultwarden:
    image: vaultwarden/server:1.37.4
    container_name: vw
    restart: unless-stopped
    environment:
      DOMAIN: "https://vault.lab.internal:8080"
      SIGNUPS_ALLOWED: "${SIGNUPS_ALLOWED:-false}"
    volumes:
      - vw_data:/data
  caddy:
    image: caddy:2.10
    container_name: caddy
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
volumes:
  vw_data:
  caddy_data:

The Caddyfile has one site, https://vault.lab.internal:8080, with tls internal and reverse_proxy vaultwarden:80. The wiki’s proxy examples add compression and an X-Real-IP header for fail2ban; neither was used here.

Vaultwarden web vault 2026.7.0 listing synthetic logins item-00000 to item-00006 with example.invalid usernames

Measured numbers

Vaultwarden 1.37.4 with 2,000 items behind Caddy (TLS from Caddy's internal CA)
MeasurementResult
Items in the vault2,000 logins (name, username, password, URI, notes), all verified by decrypting after the sync
Adding the 2,000 items through `POST /api/ciphers`, 8 parallel clients5.9 s
Password login, `POST /identity/connect/token` (server side), 3 runs124 ms (124 to 126 ms)
`GET /api/accounts/profile`, 3 runs5.6 ms (4.9 to 6.9 ms)
`GET /api/sync` with 2,000 items, 3 runs102 ms (68 to 118 ms)
Sync response size3.29 MB
Data directory, account only0.3 MB (db.sqlite3 0.3 MB)
Data directory, 2,000 items, live60.2 MB (db.sqlite3 3.1 MB, write-ahead log 57.0 MB)
Data directory after `docker stop` (WAL still present)60.2 MB (`docker stop` exit 0, took 0.3 s)
Data directory after `wal_checkpoint(TRUNCATE)` with the container stopped3.1 MB (db.sqlite3 3.1 MB)

Median of 3 runs with the range in parentheses for the login, profile and sync rows; the item-adding time and the data-directory sizes are single measurements. Client and server in the same VM over loopback, so network time is about zero. Login time excludes the client's own 600,000-iteration PBKDF2, which runs once per process outside the timer. VM 4 vCPU, 4 GB.

RAM of the Vaultwarden 1.37.4 and Caddy 2.10 containers (MiB, docker stats, page cache excluded)
Momentvaultwarden mediancaddy medianBoth, medianBoth, peak sampleSamples
Idle, account only (0 items)7.312.119.419.819
Idle after 2,000 items were added (30 s later)34.817.252.052.419
During 3 login + profile + sync rounds (about 2 s between samples)82.417.299.6117.04
During 8 parallel clients looping the same requests (about 2 s between samples)72.317.289.5117.015

VM 4 vCPU, 4 GB. Samples come from `docker stats --no-stream` and are about 2 s apart (not 1 s); peaks are maxima of those samples. Items are synthetic logins encrypted client-side. Vaultwarden's own SQLite backend, no external database.

  • Idle RAM of the pair was 19.4 MiB with an account and no items, of which Caddy was 12.1 MiB. All RAM samples here come from docker stats --no-stream and are about 2 s apart; peaks are maxima of those samples. The single-container idle figure in the stack table below (7.4 MiB) was taken on an idle stack with no account and excludes Caddy.
  • After the 2,000 items arrived, Vaultwarden itself settled at 34.8 MiB 30 s later. During a burst of 8 parallel clients repeating login, profile and sync, the highest sample for both containers together was 117.0 MiB. The 3-round row has only 4 samples because those requests finish in seconds, so read its peak as indicative.
  • The write-ahead log is the surprise in the disk numbers. After the items were added, db.sqlite3 was 3.1 MB but the -wal file next to it was 57.0 MB. docker stop (exit status 0 after 0.3 s, so not a kill timeout) did not fold it back; only an explicit PRAGMA wal_checkpoint(TRUNCATE) with the container stopped brought the directory down to 3.1 MB. Whether the log shrinks by itself later was not tested. Back up the directory as a whole (or use SQLite’s own backup tooling), never just db.sqlite3.
Idle footprint, measured on the reference test box. Median of 12 samples after settling.
StackVersionRAM at idleCPU at idleImages on diskReady after
Vaultwarden1.37.47.4 MiB0.00%402 MB2 s

The 2,000 items are synthetic logins created by client.py, which implements the Bitwarden registration and encryption flow in Python. Items were encrypted client-side, and after the sync every item decrypted back to the exact seeded text (2,000 of 2,000). Real vaults carry attachments, longer notes, icons and more devices, which this test did not model.

Signups and the admin token

Disabling signups (`SIGNUPS_ALLOWED=false`)
StepResult
`SIGNUPS_ALLOWED=true`: `send-verification-email` then `register/finish` for the test accountregister 200
Container recreated with `SIGNUPS_ALLOWED=false`; new email asks for `/identity/accounts/register/send-verification-email`HTTP 400: Registration not allowed or user already exists
The existing account logs in and syncs after the changeyes, login 118 ms, sync 98 ms

Tested through the API only. Invitations, SSO and the web form were not tested.

Registration in this test went through POST /identity/accounts/register/send-verification-email and register/finish, the flow the 1.37.4 API accepted. After the container was recreated with SIGNUPS_ALLOWED=false, the first of those two calls for a new email address was refused with HTTP 400 and the message “Registration not allowed or user already exists”. The existing account kept working. Invitations, SSO and the web signup form were not tested.

With no ADMIN_TOKEN in the compose file (the shipped default), GET /admin returned HTTP 200 with the text “The admin panel is disabled, please configure the ‘ADMIN_TOKEN’ variable to enable it”, and posting an empty token or token=anything returned HTTP 404; the test did not probe the page further.

`/admin` with a plain and an argon2-hashed `ADMIN_TOKEN`
CaseResult
No `ADMIN_TOKEN` in the compose file (the shipped default): `GET /admin`, `POST /admin` with an empty token, `POST /admin` with `token=anything`HTTP 200 (body: "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it"), 404, 404
Plain token, wrong token posted to /adminHTTP 401
Plain token, right token posted to /adminHTTP 200
Plain token, startup log[NOTICE] You are using a plain text `ADMIN_TOKEN` which is insecure.
Hash from `vaultwarden hash` (default preset)$argon2id$v=19$m=65540,t=3,p=4...
Hashed token set through Compose with `$` written as `$$`; wrong tokenHTTP 401
Hashed token; the password the hash was made fromHTTP 200
Hashed token; the PHC string itself posted as the tokenHTTP 401
Value stored in the container environmentADMIN_TOKEN=$argon2id$v=...

Status codes from curl against Caddy over HTTPS. The admin page was fetched with the cookie from the successful login.

The wiki describes a built-in PHC generator (vaultwarden hash) and says to escape every $ in the generated string as $$ in Docker Compose. Both were followed. The command needed a terminal, so the test ran it under script. With a plain token the container logged a [NOTICE] that the token is insecure; with the hash the posted password was accepted (HTTP 200) and the PHC string itself was not (HTTP 401), which is the expected behavior for a hashed token. The check was done by posting to /admin, not by clicking through the admin page. The plain token in the test (lab-only-not-a-secret) is public, so never use it outside a throwaway lab; an admin page that is reachable from the internet should have a hashed token and ideally no public route at all.

Steps that were run

SIGNUPS_ALLOWED=true docker compose up -d             # Vaultwarden + Caddy (internal CA); signups on only to create the test account
docker cp caddy:/data/caddy/pki/authorities/local/root.crt .   # root to trust on clients
SIGNUPS_ALLOWED=false docker compose up -d            # recreate with signups disabled (the compose default)
# plain-token check: plain.yml (written by admin.sh) sets ADMIN_TOKEN to the lab placeholder, applied with -f compose.yml -f plain.yml
script -qec "docker exec -it vw /vaultwarden hash" /dev/null   # asks for the password twice
docker compose -f compose.yml -f hashed.yml up -d     # hashed.yml sets ADMIN_TOKEN with $ written as $$
lab/tests/vaultwarden/run.sh
Show the full file
#!/usr/bin/env bash
# vaultwarden: Vaultwarden 1.37.4 behind Caddy (internal CA). RAM, login/sync timings with 2,000 items, SQLite size,
# SIGNUPS_ALLOWED=false, and an argon2-hashed ADMIN_TOKEN. Items are synthetic and encrypted client-side by client.py.
set -uo pipefail
source "$(dirname "$0")/../_e/lib.sh"
T=vaultwarden; D="$ROOT/lab/tests/$T"; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; N=2000; REPS=3
rm -rf "$OUT"; mkdir -p "$RAW"
start_vm "${VMP:-e}-$T" || exit 1
collect_env "$OUT"
gx 'sudo apt-get install -y -qq python3-cryptography >/dev/null 2>&1; echo "127.0.0.1 vault.lab.internal" | sudo tee -a /etc/hosts >/dev/null; mkdir -p ~/w'
for f in compose.yml Caddyfile client.py; do vm scp-to "$D/$f" w/; done
vm scp-to "$ROOT/lab/tests/_e/sampler.py" w/
gx 'cd w && docker pull -q vaultwarden/server:1.37.4 >/dev/null && docker pull -q caddy:2.10 >/dev/null; docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}" ' > "$RAW/images.txt"
# --- phase 1: signups on, plain token
T0=$(now_ms)
gx 'cd w && SIGNUPS_ALLOWED=true docker compose up -d 2>&1 | tail -2; for i in $(seq 1 120); do curl -fsk --resolve vault.lab.internal:8080:127.0.0.1 https://vault.lab.internal:8080/alive >/dev/null && echo "ready after ${i}s" && exit 0; sleep 1; done; exit 1' | tee "$RAW/01-up.txt"
echo "up_wall_ms=$(( $(now_ms) - T0 ))" >> "$RAW/01-up.txt"
gx 'docker cp caddy:/data/caddy/pki/authorities/local/root.crt ~/w/root.crt && openssl x509 -in ~/w/root.crt -noout -subject -issuer -dates' > "$RAW/02-caddy-root-ca.txt"
gx 'curl -sv --cacert ~/w/root.crt https://vault.lab.internal:8080/alive 2>&1 | grep -Ei "SSL connection|subject:|issuer:|HTTP/|expire date"' > "$RAW/03-tls-handshake.txt"
gx 'curl -s -o /dev/null -w "plain http to caddy port: %{http_code}\n" --max-time 5 http://127.0.0.1:8080/alive; true' > "$RAW/03b-http-on-tls-port.txt" 2>&1
gx 'curl -s --cacert ~/w/root.crt https://vault.lab.internal:8080/api/version; echo' > "$RAW/04-version.txt"
gx 'docker exec vw sh -c "cat /web-vault/version.json 2>&1; ls /web-vault | head -5"' > "$RAW/04b-web-vault-version.txt" 2>&1
CL='cd ~/w && SSL_CERT_FILE=$HOME/w/root.crt python3 client.py'
gx "$CL register" > "$RAW/05-register.txt" 2>&1
sleep 20
gx "sudo timeout 40 python3 w/sampler.py /tmp/mem.csv 2 || true; cat /tmp/mem.csv" > "$RAW/mem-idle-0-items.csv" 2>&1
gx 'sudo ls -l /var/lib/docker/volumes/w_vw_data/_data/ | grep sqlite; sudo du -sb /var/lib/docker/volumes/w_vw_data/_data' > "$RAW/db-size-0-items.txt"
S=$(now_ms)
gx "$CL seed $N" | tee "$RAW/06-seed.txt"
echo "seed_wall_ms=$(( $(now_ms) - S ))" >> "$RAW/06-seed.txt"
gx "$CL verify $N" > "$RAW/07-verify.txt" 2>&1
gx 'sudo sqlite3 -version >/dev/null 2>&1 || sudo apt-get install -y -qq sqlite3 >/dev/null 2>&1; sudo sqlite3 /var/lib/docker/volumes/w_vw_data/_data/db.sqlite3 "select count(*) from ciphers; select count(*) from users; pragma journal_mode;"; sudo ls -l /var/lib/docker/volumes/w_vw_data/_data/ | grep sqlite; sudo du -sb /var/lib/docker/volumes/w_vw_data/_data' > "$RAW/db-size-2000-items.txt"
sleep 30
gx "sudo timeout 40 python3 w/sampler.py /tmp/mem.csv 2 || true; cat /tmp/mem.csv" > "$RAW/mem-idle-2000-items.csv" 2>&1
# timed requests, 3 reps, with the sampler running
gx 'nohup sudo python3 w/sampler.py /tmp/memb.csv 1 >/dev/null 2>&1 & echo $! > /tmp/sampler.pid'
sleep 3
gx "$CL bench $REPS" > "$RAW/08-bench.json" 2>&1
sleep 3
gx 'sudo kill $(cat /tmp/sampler.pid); sleep 1; cat /tmp/memb.csv' > "$RAW/mem-during-bench.csv"
# RAM during a burst: 8 parallel sync loops
gx 'nohup sudo python3 w/sampler.py /tmp/memc.csv 1 >/dev/null 2>&1 & echo $! > /tmp/sampler.pid'
sleep 3
gx "$CL bench 1 >/dev/null; for i in 1 2 3 4 5 6 7 8; do ($CL bench 3 >/dev/null &) ; done; sleep 25" > /dev/null 2>&1
gx 'sudo kill $(cat /tmp/sampler.pid); sleep 1; cat /tmp/memc.csv' > "$RAW/mem-during-burst.csv"
# SQLite size after a clean stop (WAL folded into the main file), then start again
gx 'cd w && S=$(date +%s%3N); docker stop vw >/dev/null; RC=$?; echo "docker_stop_exit=$RC ms=$(( $(date +%s%3N) - S ))"; echo "## after clean stop" && sudo ls -l /var/lib/docker/volumes/w_vw_data/_data/ | grep sqlite; sudo du -sb /var/lib/docker/volumes/w_vw_data/_data; echo "## after PRAGMA wal_checkpoint(TRUNCATE) with vw stopped"; sudo sqlite3 /var/lib/docker/volumes/w_vw_data/_data/db.sqlite3 "pragma wal_checkpoint(truncate);"; sudo ls -l /var/lib/docker/volumes/w_vw_data/_data/ | grep sqlite; sudo du -sb /var/lib/docker/volumes/w_vw_data/_data; docker start vw >/dev/null' > "$RAW/db-size-2000-items-after-clean-stop.txt" 2>&1
# --- phase 2: signups off
gx 'cd w && SIGNUPS_ALLOWED=false docker compose up -d 2>&1 | tail -3; sleep 8; docker exec vw env | grep SIGNUPS' > "$RAW/09-signups-off-up.txt" 2>&1
gx "cd ~/w && SSL_CERT_FILE=\$HOME/w/root.crt VW_EMAIL=latecomer@lab.invalid python3 client.py trysignup" > "$RAW/10-signup-new-user-signups-off.txt" 2>&1
gx "$CL bench 1" > "$RAW/11-existing-user-still-logs-in.json" 2>&1
# --- phase 3: admin token, plain then argon2
vm scp-to "$D/admin.sh" w/
gx 'bash ~/w/admin.sh' > "$RAW/12-admin-token.txt" 2>&1
# --- screenshot: web vault with the 2,000 synthetic items
mkdir -p "$ROOT/public/shots/$T"
tunnel 18091 8080
( cd "$D" && node vault-shot.mjs "https://vault.lab.internal:18091/" "$ROOT/public/shots/$T" ) > "$RAW/15-shots.txt" 2>&1 || echo "shot failed" >> "$RAW/15-shots.txt"
untunnel
python3 "$D/summarize.py" "$RAW" "$OUT"

What went wrong

The first attempts to take the web vault screenshot failed because Vaultwarden’s web vault shows an extension-promotion page and a product tour after the first unlock; the screenshot script now dismisses both, and the picture is taken at the end of the measured run itself, after the admin-token checks, with signups off, a hashed admin token and the same 2,000 synthetic items. Neither affects the measurements.

What this test did not cover

Sources and how this was made

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.