Apps

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.

Edited by Simon Carter ·

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.
Per-container figures for Vaultwarden with Caddy
ContainerIdle RAMPeak RAM (under load)Image, compressed (download)Image, unpacked (disk)
caddy14 MiB18 MiB24 MB89 MB
vaultwarden10 MiB66 MiB90 MB402 MB
Total24 MiB83 MiB114 MB491 MB
Total RAM after the stack became healthy (10 s averages)
Total RAM across all containers in 10 s averages, from the moment the stack was healthy until the end of the idle window (170 s). Highest 10 s average 24 MiB at 140 s, last 24 MiB at 170 s. Dashed line: the stack counted as settled at 131 s.

0 s to 170 s after healthy; the line spans 0 to 24 MiB. Dashed line: settled.

Timeline as a table
Total RAM, 10 s average ending at each time, every 30 s after healthy
Seconds after healthyTotal RAM
3023 MiB
6023 MiB
9023 MiB
12023 MiB
15024 MiB
17024 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.

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.

compose.yamlVerified 2026-10-02 · Vaultwarden with Caddy 1.37.3Download compose.yaml for Vaultwarden with Caddy
# 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).

.envVerified 2026-10-02 · Vaultwarden with Caddy 1.37.3Download .env for Vaultwarden with Caddy
# 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:

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:

  1. 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.
  2. Set DOMAIN=vault.yourdomain.com in .env.
  3. Delete the tls internal line from the Caddyfile in compose.yaml. For a public domain Caddy then obtains a certificate on its own and redirects HTTP to HTTPS.
  4. Run docker compose up -d. Keep the caddy-data volume: 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.

  1. Install Docker Engine with the Compose plugin, version 2.23.1 or newer.

  2. Make a folder and save the two files above as compose.yaml and .env in it. The download links are on each block.

    mkdir vaultwarden
    cd vaultwarden
  3. 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 -t it exited with a panic when we tried it (we gave it a pseudo-terminal with script).

    docker run --rm -it vaultwarden/server:1.37.3 /vaultwarden hash

    Our 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.324054ms

    The command prints ADMIN_TOKEN='...'. In .env, replace the commented-out line # VAULTWARDEN_ADMIN_TOKEN= with VAULTWARDEN_ADMIN_TOKEN= followed by the hash, keeping its single quotes: copy only the part after ADMIN_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).

  4. Set DOMAIN in .env too. We left vault.example.com, a name that exists only inside the Compose network (the caddy service has it as a network alias) and in our curl --resolve options below.

  5. Start the stack. Your .env now 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). --wait returns when the containers are healthy.

    docker compose up -d --wait

    On our host, with the images already pulled, this returned after 3.7 s.

  6. 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/tcp

    Vaultwarden shows only 80/tcp, which is the port the container exposes, not a published one. Port 2019 on Caddy is also not published.

  7. Check HTTPS and the redirect from plain HTTP. The --resolve options send vault.example.com to the local host; -k skips certificate verification because of tls 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/alive

    The 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
  8. 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.py in 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:

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.

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:

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:

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

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.

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.