Vaultwarden Docker Compose: Verified 1.37.3 with Caddy
Vaultwarden Docker Compose file with Caddy HTTPS, verified on 1.37.3: 24 MiB idle, 83 MiB peak. Hashed admin token, healthcheck, backup and restore.
Evidence: TestedBacked by a Docker run in our lab.How we label evidence
Tested: Vaultwarden with Caddy 1.37.3 on · idle 24 MiB · peak 83 MiB
This Vaultwarden Docker Compose file runs Vaultwarden 1.37.3 behind Caddy 2.11.4, which provides the HTTPS the web vault needs. We verified it on Oct 2, 2026. The health check passed through Caddy 3.7 s after docker compose up. The two containers idled at 24 MiB and peaked at 83 MiB under a synthetic one-user load on a 4-vCPU lab host, so by our sizing rule the stack needs 1 GB of RAM. Three settings decide whether it is usable and safe: HTTPS in front of the web vault, signups switched off, and an Argon2 hash instead of a plain-text admin token. The file sets the first two. The published .env ships with no usable admin token, so you add your own hash before the first start (steps 3 and 4). We also backed up the running database and restored it into a separate Compose project, leaving the live stack untouched.
Test box: Vaultwarden with Caddy 1.37.3
Measured in our lab on . The numbers below come from the raw result file.
- Idle RAM
- 24 MiB all containers, mean over 30 s after the stack settled (131 s after healthy)
- Startup peak RAM
- 24 MiB highest total between healthy and settled
- Peak RAM
- 83 MiB under load
- Settled after
- 131 s after the first healthy response
- Idle CPU
- 0% of one core, lab host
- Startup
- 3.7 s to first healthy response
- App version
- 1.37.3
- Tested
- (compose file verified the same day)
- Lab host
- Intel(R) Xeon(R) Processor @ 2.10GHz, 4 cores, 15.7 GiB RAM, x86_64.Ubuntu 24.04.4 LTS, Docker 29.6.2.
- How idle was measured
- After the stack was healthy and set up, we sampled total RAM until it stayed within max(2% of its mean, 10 MiB) over 60 s (at least 120 s, at most 600 s), then measured idle over the next 30 s. Rule in full
- Peak RAM measured
- Under load. One account, one user, over HTTPS through Caddy. The script first creates 100 encrypted vault items, then for 60 seconds four threads loop over GET /alive, GET /api/config and GET /api/sync (the full vault, about 100 items), and every fifth loop update one item (a database write). It is a synthetic single-user load: no browser, no browser extension, no websocket connections, no attachments. The memory peak is measured over those 60 seconds.
| Container | Idle RAM | Peak RAM (under load) | Image, compressed (download) | Image, unpacked (disk) |
|---|---|---|---|---|
| caddy | 14 MiB | 18 MiB | 24 MB | 89 MB |
| vaultwarden | 10 MiB | 66 MiB | 90 MB | 402 MB |
| Total | 24 MiB | 83 MiB | 114 MB | 491 MB |
0 s to 170 s after healthy; the line spans 0 to 24 MiB. Dashed line: settled.
Timeline as a table
| Seconds after healthy | Total RAM |
|---|---|
| 30 | 23 MiB |
| 60 | 23 MiB |
| 90 | 23 MiB |
| 120 | 23 MiB |
| 150 | 24 MiB |
| 170 | 24 MiB |
RAM is MiB (220 bytes); image sizes are MB (106 bytes). Per-container peaks happen at different moments, so they do not add up to the total peak.
Download verified compose fileDownload .envRaw result JSONHow we measure
Lab notes (4)
- Caddy uses `tls internal` (no public domain in the lab); health check and scripts skip certificate verification for that reason.
- ADMIN_TOKEN in the stack is an Argon2 PHC hash made with `vaultwarden hash`; the plaintext lab password is only in test.yaml.
- The idle figure includes Caddy. The vault is idle (one account, one item) until the load step.
- Run behind a TLS-intercepting proxy: an extra CA bundle was mounted read-only into every container and the SSL_CERT_FILE-style variables pointed at it (lab-only override, NOT in the published compose). It changes outbound TLS trust, nothing else.
Verified compose file
Two services: vaultwarden (the server, SQLite database in a named volume) and caddy (the HTTPS reverse proxy). Only Caddy publishes ports: 80 and 443, plus 443/udp for HTTP/3. Vaultwarden listens on port 80 on the Compose network only, so from outside that network the vault is reachable only through Caddy’s TLS. The images are pinned to 1.37.3 and 2.11.4, with the digests we tested in comments. The comments in the file are the lab’s.
The Caddyfile is inside this file, under configs, so the one file you copy is the file we ran. Inline content needs Docker Compose 2.23.1 or newer (Docker’s configs reference); we ran Compose 5.3.1.
# Verified compose for Vaultwarden with Caddy, tested 2026-10-02 by the SelfHostBench lab.
# Lab host: Intel(R) Xeon(R) Processor @ 2.10GHz, 4 vCPU, 15.7 GiB RAM, x86_64, Docker 29.6.2.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: vaultwarden-caddy.json (schema 2).
# Companion env file: vaultwarden-caddy.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In vaultwarden-caddy.env:
# VAULTWARDEN_ADMIN_TOKEN is commented out. Uncomment it and set your own value (the comment above it explains how).
# Vaultwarden (unofficial Bitwarden-compatible server, written in Rust) behind Caddy.
# Caddy terminates HTTPS and is the only service that publishes ports. The web vault needs HTTPS.
# The lab has no public domain, so the Caddyfile (inline, under `configs`) uses `tls internal`:
# Caddy signs the certificate with its own CA. To use a real domain, see the comments in the Caddyfile.
# Needs Docker Compose 2.23.1 or newer (inline `configs` content).
name: vaultwarden-caddy
services:
vaultwarden:
image: vaultwarden/server:1.37.3 # sha256:1587c45feaa479f1f5e8af3b00eded36bff77bcf1880cf8dbf0541706dd470e0 (version 1.37.3, from label:org.opencontainers.image.version)
restart: unless-stopped
environment:
# The public URL of the vault. Set DOMAIN in the .env file.
DOMAIN: "https://${DOMAIN}"
# false after the first account exists. New users can then only join by admin invite.
SIGNUPS_ALLOWED: "false"
# Argon2 PHC hash of the admin password, generated with `vaultwarden hash`. It lives in the
# .env file, because Compose would otherwise read the $ signs in the hash as variables.
ADMIN_TOKEN: "${VAULTWARDEN_ADMIN_TOKEN}"
volumes:
# Everything lives here: database, attachments, sends, RSA keys, config.json. Back this up.
- vw-data:/data/
# The image already defines a check (/healthcheck.sh, every 60 s). This keeps that script and
# tightens the timing, so Caddy is not held back for up to a minute at start.
healthcheck:
test: ["CMD", "/healthcheck.sh"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
start_interval: 2s
caddy:
image: caddy:2.11.4 # sha256:0c994536bddb66445885237f1a5dcc1916bccea922661c76b4e9fc24061f9b52 (version v2.11.4, from label:org.opencontainers.image.version)
restart: unless-stopped
depends_on:
vaultwarden:
condition: service_healthy
ports:
- "80:80" # Redirects to HTTPS. Also used for the Let's Encrypt HTTP challenge.
- "443:443"
- "443:443/udp" # HTTP/3
environment:
DOMAIN: "${DOMAIN}"
configs:
- source: caddyfile
target: /etc/caddy/Caddyfile
volumes:
# Certificates and Caddy's internal CA. Without these, every re-create issues new ones.
- caddy-data:/data
- caddy-config:/config
networks:
default:
# Lets other containers on this network reach the vault by its public name.
aliases:
- "${DOMAIN}"
configs:
caddyfile:
# Written inside a Compose file, so every Caddy {$VAR} is spelled {$$VAR} here.
content: |
{$$DOMAIN} {
# Self-signed by Caddy's own CA: browsers will warn until you trust it. Lab use only.
# With a real domain that points at this host (ports 80 and 443 open), delete this
# line: Caddy then gets a Let's Encrypt certificate by itself.
tls internal
# The wiki's Caddy example enables this. Remove it if attachment downloads fail in Firefox.
encode zstd gzip
# One proxy line for everything, as in the Vaultwarden wiki's Caddy example.
reverse_proxy vaultwarden:80 {
# Passes the client address to Vaultwarden for its logs.
header_up X-Real-IP {remote_host}
}
}
volumes:
vw-data:
caddy-data:
caddy-config:The .env goes next to it. It holds the domain and a commented-out admin token line.
The published .env ships with no usable admin token, on purpose. The lab ran this stack with its own Argon2 hash, but the password behind that hash is in our public test config. Anyone who kept our hash would have an admin page that anyone can log in to. A placeholder in its place would not be safer. We tried CHANGE_ME_run__vaultwarden_hash__see_article as the value: Vaultwarden 1.37.3 treated it as a plain-text admin password and the admin login with that exact text returned HTTP 200, so the placeholder would itself be a known password. So the line is commented out instead. With it commented out, Compose prints a warning that VAULTWARDEN_ADMIN_TOKEN is not set, Vaultwarden logs that the admin page is disabled, GET /admin returns the text “The admin panel is disabled, please configure the ‘ADMIN_TOKEN’ variable to enable it”, and POST /admin returns 404 (lab/notes/vaultwarden-2026-10-02.md, sections 11 and 12). The stack still starts healthy.
The files you download are therefore not byte for byte the files the lab ran. The compose.yaml has two extra header comment lines. The .env has the lab’s hash removed and a comment explaining how to add yours. Everything the lab measured ran with the lab’s hash in place.
Signups are off in this file and the first account comes from an admin invitation, so without a hash you cannot create the first account. Generate your own hash and put it in the .env before the first start (steps 3 and 4).
# The name you will open the vault at. In the lab it only resolves inside the Compose network.
# For a real deployment set your own domain (its DNS record must point at this host).
DOMAIN=vault.example.com
# ADMIN PAGE: OFF until you set your own hash. The lab's hash is not published: the password behind it is public, so anyone
# who kept it would have an admin page that anyone can log in to. A placeholder would not be safe either, because Vaultwarden
# takes any text that is not an Argon2 hash as a plain-text admin password. Until you set a hash, docker compose prints
# 'The "VAULTWARDEN_ADMIN_TOKEN" variable is not set. Defaulting to a blank string.' on every command: that is expected.
# To turn the admin page on:
# 1. Generate your own hash (it asks for the password twice and needs a terminal):
# docker run --rm -it vaultwarden/server:1.37.3 /vaultwarden hash
# 2. Replace the line below with VAULTWARDEN_ADMIN_TOKEN= followed by the hash, keeping its single quotes. The command prints
# ADMIN_TOKEN='$argon2id$...': copy only the part after the = sign.
# 3. Run docker compose up -d. To turn the page off again, comment the line out again.
# VAULTWARDEN_ADMIN_TOKEN=It differs from the examples in Vaultwarden’s own docs in a few ways. The README’s compose example has no proxy and binds a host port on 127.0.0.1. The wiki’s Caddy example uses a separate Caddyfile, bind mounts, a Let’s Encrypt e-mail address and SIGNUPS_ALLOWED: "true". Ours uses named volumes, keeps the Caddyfile inline, sets signups to false from the first start, takes the admin hash from .env, and tightens the healthcheck timing (see the healthcheck section).
What the Caddyfile does:
{$$DOMAIN}is the site address. Compose reads a$in inline content as the start of a variable (Docker’s reference says inline content is interpolated), so$$is how a literal$is written. Caddy receives{$DOMAIN}, its own placeholder for an environment variable, as in the wiki’s example.tls internalmakes Caddy sign the certificate with its own CA. Caddy’stlsdocs describe it as using “Caddy’s internal, locally-trusted CA”. Browsers and phones do not trust that CA until you install its root certificate, so this line is for a lab or a private test. In our runcurlwithout-kfailed with exit status 60, and the certificate issuer wasCaddy Local Authority - ECC Intermediate.encode zstd gzipis in the wiki’s example. The wiki notes it may cause trouble with some browsers, for example attachment downloads in Firefox, and says to remove it then. Not tested in our lab: any browser.reverse_proxy vaultwarden:80sends everything to Vaultwarden. Theheader_up X-Real-IP {remote_host}line is from the wiki, which says it lets Vaultwarden log the true client address for tools such as fail2ban.
Switch to a real domain
Not run in our lab: it needs a public domain, and the lab has none. This is what Caddy’s automatic HTTPS docs say applies:
- Point a DNS record for your domain at the host, and make ports 80 and 443 reachable from the internet. The docs say the HTTP challenge needs port 80 and the TLS-ALPN challenge needs port 443.
- Set
DOMAIN=vault.yourdomain.comin.env. - Delete the
tls internalline from the Caddyfile incompose.yaml. For a public domain Caddy then obtains a certificate on its own and redirects HTTP to HTTPS. - Run
docker compose up -d. Keep thecaddy-datavolume: Caddy stores its certificates there.
If the host is not reachable from the internet, the wiki has a separate DNS challenge example that needs a custom Caddy build. We did not run it.
Install steps
We ran these steps in a scratch directory on the lab host on Oct 2, 2026 (Docker 29.6.2, Compose 5.3.1). The numbers on this page come from the lab run in vaultwarden-caddy.json. The raw output of the manual steps is in lab/notes/vaultwarden-2026-10-02.md in the lab repository.
-
Install Docker Engine with the Compose plugin, version 2.23.1 or newer.
-
Make a folder and save the two files above as
compose.yamland.envin it. The download links are on each block.mkdir vaultwarden cd vaultwarden -
Generate your admin token hash before the first start. The command asks for the password twice. It needs an interactive terminal: with piped input and no
-tit exited with a panic when we tried it (we gave it a pseudo-terminal withscript).docker run --rm -it vaultwarden/server:1.37.3 /vaultwarden hashOur output, for a throwaway password, with the hash cut short:
Generate an Argon2id PHC string using the 'bitwarden' preset: Password: Confirm Password: ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$cqIR6p2J...' Generation of the Argon2id PHC string took: 180.324054msThe command prints
ADMIN_TOKEN='...'. In.env, replace the commented-out line# VAULTWARDEN_ADMIN_TOKEN=withVAULTWARDEN_ADMIN_TOKEN=followed by the hash, keeping its single quotes: copy only the part afterADMIN_TOKEN=. The password you typed is what you will use to log in to the admin page. We made this edit with a script and then started the stack: the admin login page appeared, the right password returned HTTP 200 and a wrong one 401, and the log held no plain-text warning (notes, section 12). -
Set
DOMAINin.envtoo. We leftvault.example.com, a name that exists only inside the Compose network (thecaddyservice has it as a network alias) and in ourcurl --resolveoptions below. -
Start the stack. Your
.envnow has the hash, so Compose prints no “variable is not set” warning. The first start downloads the images: 114.2 MB compressed in total (table below).--waitreturns when the containers are healthy.docker compose up -d --waitOn our host, with the images already pulled, this returned after 3.7 s.
-
Check the containers and the published ports.
docker compose ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}'Our output:
NAME STATUS PORTS vaultwarden-caddy-caddy-1 Up Less than a second 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:443->443/udp, 2019/tcp vaultwarden-caddy-vaultwarden-1 Up 3 seconds (healthy) 80/tcpVaultwarden shows only
80/tcp, which is the port the container exposes, not a published one. Port 2019 on Caddy is also not published. -
Check HTTPS and the redirect from plain HTTP. The
--resolveoptions sendvault.example.comto the local host;-kskips certificate verification because oftls internal. Our lab sandbox sits behind an HTTPS proxy, so our runs also passed--noproxy. You will not need that.curl -sk --resolve vault.example.com:443:127.0.0.1 -o /dev/null -w 'https /alive: %{http_code} http_version=%{http_version} ssl_verify_result=%{ssl_verify_result}\n' https://vault.example.com/alive curl -si -m 5 --resolve vault.example.com:80:127.0.0.1 http://vault.example.com/aliveThe first command printed
https /alive: 200 http_version=2 ssl_verify_result=20, curl’s code for an unknown certificate issuer. The second returned the start of this response:HTTP/1.1 308 Permanent Redirect Connection: close Location: https://vault.example.com/alive Server: Caddy -
Create your account. The wiki’s admin page article says the admin page can invite users even when registration is disabled. Open
https://<your domain>/admin, log in with the password from step 3, invite your e-mail address, then open the site and create the account with that address. Not run in our lab: these browser steps. The lab made the same calls through the API with a script (vwclient.pyin the lab repository): admin login with the plaintext password,POST /admin/invite, register, log in. All four succeeded.
To stop the stack, Docker’s docker compose down removes the containers and keeps the named volumes unless you add -v, which deletes the volumes declared in the file, including the vault. Not run in our lab: the plain command.
docker compose down
Measured resources
Source: vaultwarden-caddy.json, run Oct 2, 2026 with the lab’s own admin hash in .env (the published .env leaves it out, see above) on the lab host (4 vCPU Intel Xeon @ 2.10GHz, 15.7 GiB RAM, Docker 29.6.2). Idle is the mean of 16 samples over 30 s, taken after the stack’s total memory had settled (131 s after the stack was healthy; rule on the methodology page) and after one account with one vault item existed. Startup peak is the highest total between healthy and settled. Peak is the highest value seen while the load ran.
The load was a synthetic single-user test over HTTPS through Caddy, from a Python script in a throwaway container. It created 100 encrypted vault items, then for 60 s ran four threads that looped over GET /alive, GET /api/config and GET /api/sync (the whole vault, about 100 items), and updated one item every fifth loop, which writes to the database. That is 26,626 requests with no failures. It used no browser, no browser extension, no websocket connections and no attachments.
| Container | Idle RAM, settled (MiB) | Startup peak (MiB) | Peak RAM under load (MiB) | Image compressed (MB) | Image unpacked (MB) |
|---|---|---|---|---|---|
| vaultwarden | 9.87 | 9.94 | 65.71 | 90.3 | 402 |
| caddy | 13.75 | 13.91 | 17.83 | 23.9 | 88.8 |
| Stack, highest simultaneous total | 23.62 | 23.65 | 82.77 | 114.2 | 490.8 |
The stack peak is the highest sum of both containers at one instant, so it is lower than the sum of the per-container peaks (83.54 MiB). CPU for the same run:
| Container | Idle CPU, mean (% of one core, lab host) | Load CPU, mean (% of one core, lab host) | Load CPU, max (% of one core, lab host) |
|---|---|---|---|
| vaultwarden | 0.0 | 71 | 80 |
| caddy | 0.0 | 69 | 78 |
| Stack | 0.0 | 140 | 157 |
The script also timed each request from inside the Compose network. These figures include the Python client and are valid for the lab host only:
| Request | Count | Median (ms, lab host) | 95th percentile (ms, lab host) |
|---|---|---|---|
GET /alive |
8,321 | 6.3 | 10.4 |
GET /api/config |
8,321 | 6.2 | 10.2 |
GET /api/sync (about 100 items) |
8,321 | 13.1 | 19.6 |
PUT /api/ciphers/<id> (one item) |
1,663 | 9.8 | 15.2 |
What the numbers say:
- Vaultwarden itself idled at 9.9 MiB with one account, and Caddy at 13.8 MiB. The proxy used more memory than the password server at idle.
- Under load Vaultwarden grew to 65.7 MiB. We did not look into what that memory was. The lab sampled only 2 s past the end of the load (the total was still 82 MiB), then removed the stack, so we do not know whether it falls back.
- By our sizing rule (peak times 1.5, rounded up to 1, 2, 4, 8, 16 or 32 GB) the stack needs 1 GB. The result is run-to-run noisy at this size: an earlier run of the same files the same day measured 22.6 MiB idle and 88.6 MiB peak.
- The Bitwarden-default admin hash (
m=64 MiB, t=3, p=4per the wiki) asks for 64 MiB of memory each time the admin password is checked. Not measured in our lab: our load made no admin logins.
What we did not measure: real Bitwarden clients (browser extension, mobile app, desktop), live-sync websocket connections, several users or organizations, attachments and Sends under load, ARM hosts, and memory growth over days.
Gotchas, upgrade and backup
HTTPS is required for the web vault
The Vaultwarden README says the web vault “requires the use of HTTPS and a secure context” for the Web Crypto API, so it only works over HTTPS. The wiki’s Enabling HTTPS page recommends a reverse proxy, mentions Caddy first for its built-in Let’s Encrypt support, and lists a private CA as “not recommended” because of pitfalls and inconveniences. We did not open the vault in a browser, so we did not see the failure itself. We built the file around the documented requirement: Caddy is the only service with published ports, and plain HTTP gets a 308 redirect to HTTPS (step 7). tls internal is the private-CA route. Use it to test the stack, then switch to a real domain.
The admin token: hash it, and mind the dollar signs
The admin page exists only when ADMIN_TOKEN is set. The wiki’s admin page article says to hash the token as an Argon2 PHC string, because configuration is usually stored in plain text. vaultwarden hash is the built-in generator. Its default uses the Bitwarden parameters (m=64 MiB, t=3, p=4); --preset owasp uses m=19 MiB, t=2, p=1. We checked both presets in the 1.37.3 --help output (lab/notes/vaultwarden-2026-10-02.md, section 3).
What we ran against 1.37.3, with the hash in the stack and the plaintext password typed at the login:
| Admin login attempt | Result |
|---|---|
| A wrong password | HTTP 401 |
The $argon2id... hash itself used as the password |
HTTP 401 |
| The plaintext password the hash was made from | HTTP 200 and a VW_ADMIN cookie (HttpOnly, SameSite=Strict, Secure, Max-Age=1200) |
So you log in with the plain password, never the hash. The wiki says the admin session lasts 20 minutes by default, which matches Max-Age=1200.
- Plain text prints a warning. A container started with
ADMIN_TOKEN=plain-text-tokenloggedYou are using a plain text `ADMIN_TOKEN` which is insecure.and a pointer tovaultwarden hash. The hashed stack logged no such line. - Dollar signs. A PHC string contains five
$. In a Composeenvironment:block they must be doubled to$$, or Compose reads them as variables and the token is wrong. The wiki’s alternative, which our file uses, is a.envfile with the hash in single quotes and plain$signs.docker compose configprints the value with$$(it re-escapes it), whiledocker exec ... printenv ADMIN_TOKENshowed the single-$hash, which is what Vaultwarden needs to see. - Saving settings in the admin page creates
config.json. The wiki says values in it take precedence over environment variables, so a later change to the same variable in.envis ignored. We saved one setting (invitation_org_name). The resultingconfig.jsonheld only that setting, not the admin token. Not tested in our lab: what other settings write, and the precedence rule itself.
Lock down the admin page
Through Caddy as written, /admin is reachable from anywhere that can reach your domain. With a hash set, GET /admin on our stack returned the login page (HTTP 200), so the password is the only protection. There are two ways to narrow that. The first is documented in the Vaultwarden wiki. The second is Caddy configuration; the wiki’s admin page article does not cover proxy restrictions, so its source is Caddy’s docs. We ran both on a scratch copy of the verified files, not on the file the lab measured (lab/notes/vaultwarden-2026-10-02.md, sections 11, 12 and 15).
Turn the page off after setup. The wiki’s “Disabling the admin page” section says to make sure that neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN is set and that no "admin_token" key exists in config.json (if that file exists), then recreate the container. In our file that means commenting the VAULTWARDEN_ADMIN_TOKEN line out again in .env and running:
docker compose up -d
Compose recreated the Vaultwarden container. Afterwards GET /admin returned HTTP 200 with the text “The admin panel is disabled, please configure the ‘ADMIN_TOKEN’ variable to enable it” (a 200 with no login form), and POST /admin with the old, correct password returned 404. Our data folder had no config.json at that point, so we did not test the case where it holds an admin_token key. To use the admin page again, put the hash line back and run the same command. Do not set DISABLE_ADMIN_TOKEN to get around the hash: the wiki says that gives unrestricted access to the admin panel. Not tested in our lab: DISABLE_ADMIN_TOKEN.
Block /admin in the Caddyfile. This stops requests for the page at the proxy, whatever the password. Add these two lines to the Caddyfile inside compose.yaml, above the reverse_proxy line. Tested on a scratch copy with the admin hash set; this is not part of the verified file above:
@admin path /admin /admin/*
respond @admin 403
Before the change, GET /admin, /admin/, /%61dmin and //admin all reached Vaultwarden (HTTP 200), /admin/users returned 401, and the admin login with the right password returned 200. Caddy’s path matcher is exact unless you add *, which is why the pattern has both /admin and /admin/*. Caddy’s path matcher docs say matches are case-insensitive and that the request path is normalized (URL-decoded) before matching, which is why the encoded and doubled-slash forms are covered. After the change, /admin, /admin/, /admin/users, /ADMIN, /%61dmin, //admin and the admin login all returned 403 from Caddy, while /alive and /api/config still returned 200. Two cautions from the same test:
- A plain
docker compose up -dafter editing the inline Caddyfile left the Caddy container running, and/adminstill returned 200. Runningdocker compose up -d --force-recreate --no-deps caddyapplied the change. - The block is at the proxy only. A request made inside the Vaultwarden container straight to its port 80, skipping Caddy, still reached the admin login (200). Vaultwarden publishes no port in this file, so that needs access to the Docker host or a container on the network.
To use the admin page while the block is in place, remove the two lines, recreate Caddy, and add them back afterwards.
To allow some addresses instead of none, Caddy’s remote_ip matcher matches the address of the immediate peer. This version blocks /admin for every client outside the private ranges:
@admin_outside {
path /admin /admin/*
not remote_ip private_ranges
}
respond @admin_outside 403
With it, a request from the lab host’s own loopback address got 200 for /admin: Caddy saw the client as 172.18.0.1, the Docker bridge gateway, which is inside private_ranges (section 15 of the notes). Swapping private_ranges for 192.0.2.0/24, a range that does not contain that address, returned 403. That shows both outcomes of the matcher. The first outcome is also the catch. If Docker or a proxy or tunnel on the same host hands Caddy a private address instead of the visitor’s, every visitor counts as private and this rule lets them all in to the login page. The /admin block above has no such dependency, so prefer it unless you have checked from an outside address what Caddy sees. We did not test the remote_ip rule from another machine or through another proxy. The Caddy docs say that to match the client address from HTTP headers you need the client_ip matcher instead, so remote_ip would see only your CDN or upstream proxy if you have one. Not tested in our lab: client_ip.
Signups: off from the first start
The wiki’s own compose example sets SIGNUPS_ALLOWED: "true" with a comment to switch it to false after you create your account. Our file starts at false, so there is no window in which a stranger can register. The first account is created by invitation instead (step 8), which is why the admin hash comes first: with the admin page off, nobody can send an invitation. With the setting off, an uninvited registration through the API returned HTTP 400 Registration not allowed or user already exists, and the invited address registered normally.
One trap: GET /api/config still reported "disableUserRegistration": false on our stack. The 1.37.3 source (config.rs) computes that field as true only when signups are off, the domain whitelist is empty, and either mail is enabled or invitations are disallowed (there is also an SSO-only case, which we did not use). We had no mail set up and invitations were allowed, so the field read false while uninvited registration was refused. Do not use that field to check your setting. Try an uninvited registration instead.
The healthcheck
The Vaultwarden image already defines a healthcheck. The 1.37.3 Dockerfile has HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"], and docker image inspect on our pulled image showed the same: interval 60 s, timeout 10 s. The script calls curl --insecure --fail on /alive at localhost, on the container’s own port 80. It checks the app inside its container and never goes through Caddy. Caddy’s image defines no healthcheck (docker image inspect printed null).
Our file keeps that script and changes only the timing:
healthcheck:
test: ["CMD", "/healthcheck.sh"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
start_interval: 2s
start_interval makes Docker check every 2 s during the 30 s start_period. Docker’s Compose reference lists it and notes it was introduced in Compose 2.20.2. It matters because Caddy waits for service_healthy. We ran the same file with the healthcheck: block removed, so the image’s default applied:
| Healthcheck | Time for docker compose up -d --wait |
|---|---|
| Image default (every 60 s) | 61.6 s |
| Our override (every 2 s while starting) | 3.7 s |
To check the proxy path as well, use the curl command from step 7. The lab’s own health check did that: https://vault.example.com/alive through Caddy with curl -k, because the certificate comes from Caddy’s internal CA. It passed 3.7 s after the start.
Update
The file pins exact tags, so an update is an edit. Back up first (next section), then change the tag on the vaultwarden image line and delete the stale digest comment after it. We ran the update on a stack at 1.37.2 holding one account and 11 vault items: we changed 1.37.2 to 1.37.3 with sed, then ran:
docker compose pull
docker compose up -d --wait
Afterwards /vaultwarden --version printed Vaultwarden 1.37.3, the account logged in, all 11 items decrypted, and an access token issued before the update was still accepted. Read the notes for the version you move to on the releases page first. We tested one patch update, not a major one.
The wiki’s latest example updates with docker compose pull && docker compose up -d. We pin a tag instead, because a floating tag changes the next time you pull.
Back up and restore
The wiki’s backup page sorts the data folder into three groups. Required: db.sqlite3 and the attachments folder. Recommended: config.json and the rsa_key* files. Optional: sends and icon_cache. It says to use the SQLite CLI’s .backup command (the online backup API) for a database that may be in use, or the built-in /vaultwarden backup command from version 1.32.1, and that plain file copies are an option only when the database is not in use. It also says the Docker image has no sqlite3 binary. In our data folder, after one account, one attachment, one file Send and one saved admin setting, the files were db.sqlite3 (with -shm and -wal while running), attachments/, sends/, config.json, rsa_key.pem and a tmp folder. There was no rsa_key.der or rsa_key.pub.der, which the wiki’s example tree lists, so check your own folder.
We backed up a running stack that held one account, 21 vault items, one 200,065-byte attachment and one 50,065-byte file Send. The container name comes from the project name in the file:
mkdir -p backup
docker exec vaultwarden-caddy-vaultwarden-1 /vaultwarden backup
It printed Backup to 'data/db_20261002_123933.sqlite3' was successful; the file is written inside the data volume. The wiki’s sqlite3 .backup command also worked. We ran it from a throwaway Alpine container (SQLite 3.53.4) that mounted the live volume. These two lines were saved as sqlite-backup.sh (the script we ran also printed the SQLite version and listed the backup folder):
apk add -q sqlite
sqlite3 /data/db.sqlite3 ".backup '/backup/db-cli.sqlite3'"
docker run --rm -v vaultwarden-caddy_vw-data:/data -v "$PWD/backup:/backup" -v "$PWD/sqlite-backup.sh:/s.sh:ro" alpine:3 sh /s.sh
Both files were 299,008 bytes, passed PRAGMA integrity_check, and held 1 user, 21 items, 1 attachment and 1 Send. We used the built-in file for the restore. The remaining files came out of the volume with three lines saved as files-backup.sh (ours also ended with an ls of the backup folder) and run in a throwaway container. In the lab we pulled the image as mirror.gcr.io/library/alpine:3, because Docker Hub was rate-limiting the host, and our paths were absolute:
cd /data
cp db_*.sqlite3 /backup/
tar czf /backup/vw-files.tar.gz attachments sends config.json rsa_key*
docker run --rm -v vaultwarden-caddy_vw-data:/data:ro -v "$PWD/backup:/backup" -v "$PWD/files-backup.sh:/s.sh:ro" alpine:3 sh /s.sh
The wiki says attachments, sends and config.json exist only after you have created an attachment, a Send or saved an admin setting. All three existed in our run. If one is missing from your data folder, tar prints a “No such file or directory” error for it, still writes the archive with the rest, and exits with status 1 (checked on Alpine 3 with a folder holding only rsa_key.pem, lab/notes/vaultwarden-2026-10-02.md, addendum). Remove the missing names from the tar line to silence it.
We ran the restore drill into a separate Compose project, so the live stack and its data are never touched. The -p option overrides the name: line in the file, and the volumes take the new prefix (vw-restore-test_vw-data, not vaultwarden-caddy_vw-data). Only the vaultwarden service is created, because Caddy’s ports 80 and 443 belong to the live stack. Save these lines as restore.sh, with BKN set to the name of the database backup file (we passed it with -e; the db_20261002_123933.sqlite3 in the command below is our file, so use the name from your backup folder):
tar xzf /backup/vw-files.tar.gz -C /data
cp "/backup/$BKN" /data/db.sqlite3
docker compose -p vw-restore-test create vaultwarden
docker run --rm -e BKN=db_20261002_123933.sqlite3 -v vw-restore-test_vw-data:/data -v "$PWD/backup:/backup:ro" -v "$PWD/restore.sh:/s.sh:ro" alpine:3 sh /s.sh
docker compose -p vw-restore-test up -d --wait vaultwarden
docker compose create makes the volume and the container without starting Vaultwarden, so nothing writes to the database before the files are in place. The copy publishes no port; we checked it from a throwaway container on its own project network (vw-restore-test_default). When you are done, remove only the copy:
docker compose -p vw-restore-test down -v
The -v here deletes the volumes of the vw-restore-test project only. After it, docker volume ls still listed the three live volumes. Without -p, docker compose down -v would target the live project, so keep the -p on every command of the drill. Before you rely on a backup, check that the backup folder holds a non-empty db_*.sqlite3 and vw-files.tar.gz, and copy the folder to another machine.
The wiki says to stop Vaultwarden before restoring and, when restoring over existing data, to delete any db.sqlite3-wal file first. Our target was an empty volume, so there was no -wal file. Not run in our lab: a restore over existing data, which is what replacing a lost live stack involves.
The drill ran against a live stack holding one account, 21 vault items, one attachment, one file Send and one saved admin setting. What the restored copy did, checked from a script on its project network:
- The account logged in with its original password, and all 21 items decrypted on the client side.
- The attachment downloaded with the same SHA-256 as the one uploaded before the backup.
- The one Send was listed, the admin page accepted the admin password, and
config.jsoncame back with the saved setting. - An access token issued by the live server before the backup was accepted by the copy (HTTP 200). That token is signed with the
rsa_keyfile, so this shows the key was restored.
What the live stack did meanwhile: the same two containers, with the same IDs and start times before and after; the same rsa_key.pem checksum; the same three volumes; still 21 items. The only difference in the before and after listings was the extra vw-restore-test_vw-data volume while the drill ran.
As a control we repeated the drill with --exclude='rsa_key*' on the tar line. The login worked and the 21 items decrypted, but the old access token was rejected (HTTP 401) and a new rsa_key.pem had been created. That matches the wiki’s description: without these files users are logged out and must sign in again.
Two cautions from the wiki. config.json and rsa_key.pem hold sensitive data, so encrypt backups that leave the machine. The wiki also says to avoid relying on VM or filesystem snapshots as a backup method. Your .env holds the admin hash, so if you copy it into the backup folder, treat that copy as a secret too. We did not back up icon_cache. With a real domain, Caddy’s certificates live in the caddy-data volume, so back that up too or Caddy will have to obtain new ones. Not tested in our lab.
The same rule as in our Immich restore test applies: a backup you have not restored into an empty volume is a guess.
Vaultwarden vs Bitwarden
Researched, not tested: we ran only Vaultwarden, never a Bitwarden server. Bitwarden’s help pages, read Oct 2, 2026, give 2 GB RAM minimum and 4 GB recommended for the standard Linux deployment and at least 200 MB for the Lite deployment, both as host requirements and both needing an installation ID and key from bitwarden.com/host. Those are not comparable with our measurement of two containers (24 MiB idle). Vaultwarden’s README states that the project “is not associated with Bitwarden or Bitwarden, Inc.”; we did not compare features or licensing.
What we did not measure
- Real clients: the web vault in a browser, the browser extension and the mobile apps. The lab script speaks the same API, but it is not a client.
- Live sync (websocket) connections, several users, organizations, and load with attachments or Sends.
- The Let’s Encrypt path, HTTP/3, SMTP, two-factor login, and databases other than SQLite.
- The browser steps for the admin page, the Caddy lock-down from a machine other than the lab host,
client_ip,DISABLE_ADMIN_TOKEN, and replacing live data with a restore. - The memory cost of an admin login with the Bitwarden-default hash.
- ARM hosts, and memory growth over days.
Where to run it
- Hardware. Vaultwarden with Caddy idled at 24 MiB and peaked at 83 MiB under load in our lab. Plan for at least 1 GB of RAM.
- VPS. The smallest plan that runs this comfortably has 1 GB of RAM, plus disk for the images (491 MB unpacked) and your data.
- Keep it safe. Back up the data volume before you rely on it, and put the app behind a reverse proxy with HTTPS before you expose it to the internet.
How we size: recommended RAM is the highest reading we measured (under load or during startup) times 1.5, rounded up to the next of 1, 2, 4, 8, 16 or 32 GB. The extra half covers the operating system, Docker, file cache and spikes our sampling can miss. Your own data and extra apps need more.
Related
- How the lab measures RAM, CPU and image sizes: methodology.
- Other apps in the same format: the self-hosted apps index. Running a heavier app on the same box: the Immich Docker Compose guide and its RAM numbers.
- Official sources used on this page, read Oct 2, 2026: the Vaultwarden README, the wiki pages on Docker Compose, HTTPS, the admin page and backups, Caddy’s automatic HTTPS and request matcher docs, and Bitwarden’s standard and Lite deployment pages.
Update log
- First published. Vaultwarden 1.37.3 behind Caddy 2.11.4 verified in the lab; hashed admin token, healthcheck, update, admin-page lock-down, and a live backup restored into a separate Compose project were run the same day. The published .env carries no admin token.