On this page (10)
To back up self-hosted Bitwarden that runs Vaultwarden 1.37.4 on SQLite, run docker exec <container> /vaultwarden backup for the database and tar attachments, sends, config.json and rsa_key* next to it. The server stays up. In our lab each of three runs restored that backup into an empty stack and passed 15 of 15 checks, and so did a cold copy of the whole data folder (0.93 s of downtime). Do not cp db.sqlite3 on a running server: that copy passed SQLite’s integrity check but lacked 79 to 111 of the 461 to 499 items the server had acknowledged before the copy started. Tested Oct 22, 2026, with no client writing during the online backups.
Measured readings
Test box: Vaultwarden backup and restore 1.37.4
Measured in our lab on : 3 full runs, each from a fresh stack. The numbers come from the raw result file.
This lab measures commands, not a running service. A script runs the tool inside the lab’s containers, so Docker’s idle and peak RAM for the stack would describe those containers (sleeping helpers, the file cache), not the tool. They are not shown here. The tool’s own peak memory for each step is in the measured table on this page and in the raw result file (peak_rss_mib).
- App version
- 1.37.4
- Tested
- , 3 runs (compose file verified the same day)
- How the figures are made
- Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
- Lab host
- AMD EPYC 9V74 80-Core Processor, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
- Disk for the images
- About 430 MB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 300 MB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
- What the benchmark ran
- One script inside the toolbox container, in this order. (1) Log in as the seeded user and check the live vault (225 items, field by field; 3 attachments by SHA-256; the organization; the Send). (2) Back up with M1, the SQLite online backup API (`sqlite3 db.sqlite3 ".backup"`) plus a tar of attachments, sends, config.json and rsa_key*, with Vaultwarden running. (3) M1b, Vaultwarden's own `/vaultwarden backup` plus the same tar. (4) M3, a plain `cp db.sqlite3` (main file only) while four threads keep creating items, to count how many acknowledged items the copy lacks (the extra items are deleted afterwards). (5) M2, a cold copy: `docker stop`, tar of the whole data folder, `docker start`, with the downtime measured until /alive answers. (6) Check that the live vault still verifies. (7) For each backup, restore into a FRESH Vaultwarden project (`docker compose -p vwr-...`, its own volume and network, the same pinned image), start it, log in with the original master password, and verify: all 225 items decrypt and match field by field, the attachments' SHA-256, the organization and collection, the Send, an access token issued before the backup, the rsa key and the admin setting; and the same vault is read again with the official Bitwarden CLI (login over HTTPS, `bw list items`, `bw export`, `bw get attachment`). (8) Three controls: the M1 backup restored with rsa_key*, attachments, or config.json left out. Each timed command reports its seconds and peak resident memory.
| Container | Image, compressed (download) | Image, unpacked (disk, overlay2 store) |
|---|---|---|
| toolbox (lab only, not in the totals) | 76 MB | 214 MB |
| vaultwarden | 90 MB | 300 MB |
Every run (3)
| Run | Lab host CPU |
|---|---|
| 1 | AMD EPYC 9V74 80-Core Processor |
| 2 | AMD EPYC 9V74 80-Core Processor |
| 3 | AMD EPYC 9V74 80-Core Processor |
Image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. RAM figures are MiB (220 bytes), in the article’s measured table. The disk figure leaves out the lab-only container (toolbox): it is not in the published compose file.
Download verified compose fileDownload .envRaw result JSONHow we measure
Lab notes (6)
- This is a command lab, like the restic/Borg/Kopia comparison: the harness's idle and peak RAM (docker stats of the Vaultwarden and toolbox containers) include the toolbox's file cache and the Node processes of the Bitwarden CLI, and say nothing about the backups. The numbers are in `runs[].load.reported_by_script`: per method `seconds`, `downtime_s`, `peak_rss_mib`, sizes and the restore verification. The harness does not take medians of those keys.
- Seeding: Python script (vwbackup.py) with the documented Bitwarden client cryptography (PBKDF2-SHA256 600,000 iterations for the master key, AES-256-CBC + HMAC-SHA256 EncStrings, RSA-2048 key pairs, RSA-OAEP-SHA1 for the organization key), the same as lab/apps/vaultwarden-caddy/vwclient.py. The official Bitwarden CLI (@bitwarden/cli 2026.10.0) has no command to register an account, so it cannot seed an empty server.
- The stack is Vaultwarden alone over plain HTTP on the Compose network, because a backup copies the data folder and the folder is the same behind Caddy. The web vault's HTTPS requirement concerns browsers; this test has no browser and talks to the API.
- Peak RSS: sqlite3, tar and cp run under busybox `time -v` (the kernel's high-water mark of that process). `vaultwarden backup` runs in the Vaultwarden container and is sampled by a polling loop (highest VmHWM seen), so it is a lower bound. `docker stop`/`start` are not RSS-measured (they are requests to the daemon).
- Lab-only compose override compose.lab.yaml (NOT in the published compose): Adds a `toolbox` container (docker:28.5.2-cli, the Docker socket, the live data volume and a backups volume) that runs the backup commands, stops and starts the live container and creates the separate restore projects. A backup host would run these commands from cron; the socket is there only so the lab can drive docker.
- Service(s) toolbox exist only in the lab-only compose override and are NOT in the published compose; their figures are in the result, their image line is not in the verified compose.
This page covers Vaultwarden, the unofficial Bitwarden-compatible server, with its default SQLite database. It does not cover the official Bitwarden self-host install, which is a different server with its own procedure on Bitwarden’s Backup Server Data page; we did not test it. It is the backup and restore companion to the verified Vaultwarden Docker Compose guide, which has the stack, the admin token and a smaller restore drill. We do not repeat them here.
What to back up
The Vaultwarden wiki’s backup page rates each part of the data folder. The last column is what our lab backed up and restored.
| Path | Wiki rating | In our data folder | In our backup |
|---|---|---|---|
db.sqlite3 (with -wal, -shm while running) |
Required (-shm need not be backed up) |
413,696 bytes, plus a 4,120,032-byte -wal and a 32,768-byte -shm |
Yes, as one file made by SQLite or by vaultwarden backup (499,712 bytes) |
attachments/ |
Required | 3 files, 11.55 MB | Yes, in the tar |
sends/ |
Optional (back it up if existing Sends must keep working after a restore) | 1 file, 50,065 bytes | Yes, in the tar |
config.json |
Recommended (admin-page settings) | 48 bytes | Yes, in the tar |
rsa_key* |
Recommended (signs login tokens) | rsa_key.pem, 1,675 to 1,679 bytes; no .der files |
Yes, in the tar |
icon_cache/ |
Optional | not present | No |
The folder held one account, 225 items (200 logins, 20 secure notes and 5 organization logins), three attachments of 16 KiB, 1 MiB and 10 MiB, one file Send, one organization and one saved admin setting. Source: vaultwarden-backup.json, reported_by_script.vault, identical in all three runs apart from a 4-byte difference in rsa_key.pem. Your own folder may differ: the wiki says attachments and sends exist only after an attachment or a Send file has been created and config.json only if the admin page has been enabled before (ours was created by saving one admin setting), and tar prints an error for each name that is missing (the Compose guide checked this).
Our lab backed up the files the wiki rates required and recommended, plus the optional sends. The wiki also says MySQL and PostgreSQL need a separate dump of the database. Not tested in our lab: we ran SQLite only.
Backup methods, measured
The lab ran four methods against the same live vault, one after the other, in each of three runs. Vaultwarden was running for M1, M1b and M3, and the lab script sent no writes during M1 and M1b. Every figure is the median of three runs, with the lowest and highest in brackets, from vaultwarden-backup.json (arithmetic in lab/notes/vaultwarden-backup-2026-10-22.md). Times are for the lab host only: a GitHub Actions runner with 2 vCPUs (AMD EPYC 9V74), a 16.2 MB data folder, and the sqlite3 3.49.2 command-line tool that the lab container installed.
| Method | Downtime, s | Time, s | Peak RSS, MiB | Backup size, MB | Restore: checks passed |
|---|---|---|---|---|---|
M1 sqlite3 .backup, plus tar of the files |
0 | 0.42 (0.40 to 0.48) | 3.27 (3.08 to 3.38) | 12.11 (12.11 to 12.11) | 15 of 15 in 3 of 3 runs |
M1b vaultwarden backup, plus the same tar |
0 | 0.49 (0.49 to 0.50) | 22.23 (21.92 to 22.32), a lower bound | 12.11 (12.11 to 12.11) | 15 of 15 in 3 of 3 runs |
| M2 cold copy: stop, tar the whole folder, start | 0.93 (0.92 to 0.93) | 0.93 (0.92 to 0.93) | 1.15 (1.15 to 1.16) | 13.44 (13.43 to 13.47) | 15 of 15 in 3 of 3 runs |
M3 plain cp db.sqlite3 during writes |
0 | 0.94 (0.94 to 1.04), mostly the tar under load | 1.25 (1.17 to 1.27) | 12.44 (12.43 to 12.45) | Failed: items missing (see below) |
How to read it.
- The database step is nearly free.
sqlite3 .backuptook 0.005 s (0.005 to 0.009) andvaultwarden backup0.11 s (0.10 to 0.11, includingdocker exec). The tar of 11.6 MB of mostly attachments took 0.39 to 0.47 s, which is almost all of M1 and M1b. The 0.07 s gap between them is not worth a choice; pick on tooling. - Both online methods produced a database file of the same size, 499,712 bytes in every run, and both passed
PRAGMA integrity_check. We did not compare the two files byte by byte. The tar was the same size for both (11,605,603 to 11,605,675 bytes). - RSS is the tool’s own memory. M1, M2 and M3 come from the kernel’s high-water mark of
sqlite3,tarandcp. M1b is the highestVmHWMa polling loop saw in the Vaultwarden container (42 to 45 samples), so it is a lower bound. The lab runner’s own idle and peak RAM readings for this test (157 MiB and 386 MiB, medians) include the lab’s toolbox container and the Bitwarden CLI’s Node processes, so we do not show them as backup cost. - Cold copy:
docker stoptook 0.16 s, the tar 0.56 s anddocker start0.16 s. Downtime runs from the start ofdocker stopuntil/aliveanswered.db.sqlite3-walanddb.sqlite3-shmwere still in the folder after the container stopped, in all three runs, and the whole-folder tar included them. The SQLite WAL documentation says the log is normally deleted when the last connection closes, but can stay if the last process exits without closing it cleanly. We did not look into why it stayed. We did not test tarring onlydb.sqlite3after a stop. The wiki’s own Docker example is also a stop and a copy of the whole folder. - The online methods ran with no writer. The wiki and SQLite’s guidance on backups say the online backup API is safe on a database in use. Our lab did not test
.backuporvaultwarden backupwhile a client was writing.
The commands the lab ran
The online methods ran inside a lab container that had the live data volume at /data and a backup volume at /backup; our script wrote to /backup/m1/ and /backup/m1b/. M1:
sqlite3 /data/db.sqlite3 ".backup '/backup/db.sqlite3'"
cd /data && tar czf /backup/vw-files.tar.gz attachments sends config.json rsa_key*M1b ran /vaultwarden backup through docker exec from the lab container (via the Docker socket), wrapped in a small shell loop that sampled the process’s memory. The plain command is below. It prints Backup to '<path>' was successful and writes the file into the data folder, in UTC, as db_YYYYMMDD_HHMMSS.sqlite3 (ours was db_20261022_035611.sqlite3). It sits in the same volume as the data it protects, so copy it out. The lab copied it out, deleted it from /data so the live folder stayed as it was, then ran the same tar as M1. The container name is our project’s default, so use your own from docker ps:
docker exec vaultwarden-backup-vaultwarden-1 /vaultwarden backupcp /data/db_*.sqlite3 /backup/
rm /data/db_*.sqlite3
cd /data && tar czf /backup/vw-files.tar.gz attachments sends config.json rsa_key*M2, the cold copy. The lab script drove the Docker socket; the tar ran in the lab container with the volume mounted:
docker stop vaultwarden-backup-vaultwarden-1
cd /data && tar czf /backup/vw-data.tar.gz .
docker start vaultwarden-backup-vaultwarden-1The wiki says the Docker image has no sqlite3 binary, and the final stage of the 1.37.4 Dockerfile installs only ca-certificates, curl, libmariadb3, libpq5 and openssl on Debian (we did not open the image). That is why we pick the built-in command (available from 1.32.1; its release notes say “Added CLI option to backup SQLite database”). To run M1 we installed the Alpine sqlite package in the lab container, which gave sqlite3 3.49.2; the wiki says to install it on the Docker host and run the job there. To run the tar and cp lines against a volume on a normal host, use a throwaway container that mounts it, as in the Compose guide’s backup steps; the lab ran those lines in its own container instead. The lab container mounted the Docker socket, which is root on the host. That is lab plumbing, not advice.
Why a plain copy of db.sqlite3 is not a backup
M3 copied only db.sqlite3, as a cp db.sqlite3 line in a backup script does, while four threads created login items through the API as fast as the server accepted them. The lab counted an item as acknowledged when the server had answered its create request before the cp started, then restored the copy into a fresh stack and looked for each item by name.
| Run | Items acknowledged before the copy | Present after restore | Missing | Missing share | -wal at copy, bytes |
db.sqlite3 at copy, bytes |
|---|---|---|---|---|---|---|
| 1 | 499 | 388 | 111 | 22.2% | 4,293,072 | 839,680 |
| 2 | 477 | 379 | 98 | 20.5% | 4,185,952 | 827,392 |
| 3 | 461 | 382 | 79 | 17.1% | 4,231,272 | 831,488 |
What the restored copy did:
- It failed silently. The copy passed
integrity_checkin all three runs, the restored server became healthy in 2.2 s, and all 15 of the lab’s standard checks passed, including the login, 225 of 225 seeded items matching in every field, and 3 of 3 attachments. The only failing check was a 16th, which counts the extra items. - The seeded items were intact. All 225 items created before the test were present and matched in every field. The missing ones are among the extra items the writers created during the test; the lab did not record which of those were absent.
- What we think happened, and what we did not prove. The copy took only the main file. At that moment the
-walfile was about five times its size (4.2 MB against 0.83 MB in the median). Committed changes can sit in the write-ahead log until SQLite moves them into the main file, and the SQLite WAL page says: “If a database file is separated from its WAL file, then transactions that were previously committed to the database might be lost, or the database file might become corrupted.” The wiki says the same in practice: restore a straight copy ofdb.sqlite3together with its matching-walas a pair. We never copied the-walfile in this test and never inspected which items sat in it, so the log as the cause is inferred from those sizes and the documentation. - Corruption is possible too. The SQLite page above allows for it. We saw none in three runs.
Limits of this result. Four threads wrote roughly 460 to 500 items in the second before the copy, far more than a personal vault sees. We did not test a naive copy of an idle vault, and we do not say it is safe: before any backup, with the script idle, the live db.sqlite3 was 413,696 bytes and its -wal 4,120,032 bytes, while the .backup copy of the same database was 499,712 bytes. The main file being smaller than the database it belongs to suggests committed pages were still only in the -wal; that is our reading of the sizes, not something we checked. Development runs are not in the result file: lab/README.md records a loss of 66 to 133 of 448 to 576 acknowledged items in four unpublished runs and once none. So the loss varies from run to run, and integrity_check cannot show it.
Restore, as the lab ran it
Each backup went into a fresh Compose project with its own empty volume and network, so the live vault was never touched. The lab used the same pinned image as the live stack (vaultwarden/server:1.37.4) and a stripped-down compose file with the vaultwarden service alone and no published port. The commands below are what its script ran, with our project and volume names shortened and -f added for your file. Save a copy of your compose file as compose.restore.yaml in the same folder and delete its ports: lines: the published file binds 127.0.0.1:8080, and the copy would fail to start while the live stack holds that port. Keep -p on every command, as in the Compose guide’s restore drill. The backup folder in the lab was a Docker volume; a host folder works the same way and is what the Compose guide used.
Create the project and its volume without starting Vaultwarden, so nothing writes to the database before the files are in place:
docker compose -p vw-restore-test -f compose.restore.yaml create vaultwardenPut the files in the new volume with a one-off container. For M1 and M1b (use your db_*.sqlite3 name for M1b):
docker run --rm -v vw-restore-test_vw-data:/data -v "$PWD/backup:/backup:ro" --entrypoint sh docker:28.5.2-cli -c "set -e; tar xzf /backup/vw-files.tar.gz -C /data; cp /backup/db.sqlite3 /data/db.sqlite3"For the cold copy, one tar holds everything:
docker run --rm -v vw-restore-test_vw-data:/data -v "$PWD/backup:/backup:ro" --entrypoint sh docker:28.5.2-cli -c "set -e; tar xzf /backup/vw-data.tar.gz -C /data"Start the copy, wait for the healthcheck, and remove only the copy when you are done:
docker compose -p vw-restore-test -f compose.restore.yaml up -d --wait vaultwarden
docker compose -p vw-restore-test -f compose.restore.yaml down -vThe lab then logged in to the copy and ran 15 checks. Restore to a healthy container took 2.2 s (2.18 to 2.26) for all three methods. All 15 checks passed in 9 of 9 restores (three methods, three runs).
| Checks | What each verified |
|---|---|
| Login, 1 check | The original master password logs in |
| Vault, 4 checks | 225 of 225 items found and decrypted with the account’s keys; every field of every item matches (username, password, URI, notes), including 5 organization logins; the organization and its collection are present |
| Attachments and Send, 3 checks | 3 of 3 attachments download and decrypt to the expected SHA-256; 1 Send is listed; the Send’s blob on disk has the expected SHA-256 |
| Keys and settings, 3 checks | An access token issued before the backup is still accepted (it is signed with rsa_key.pem); rsa_key.pem has the live key’s SHA-256; config.json holds the saved admin setting |
| Official Bitwarden CLI 2026.10.0, 4 checks | Login over HTTPS; bw list items returns 225 matching items; bw export holds 220 personal items; bw get attachment returns 3 of 3 matching files |
The lab’s script ran the first 11 checks in about 0.7 s. The four CLI checks took 35.3 s (34.5 to 36.0). The CLI insists on HTTPS, so the lab put a throwaway TLS proxy in front of the copy. That is test plumbing, not part of the restore.
To check your own restore by hand, the minimum we would do is: log in with your master password, compare the item count with the live vault, download one attachment and open it, and look at the admin page’s saved settings. That list is our suggestion; the lab ran scripted checks, not a browser.
Restoring over an existing install
Not run in our lab: restoring over an existing install, which is what replacing a broken server involves. Our target volumes were empty, so no -wal existed. The wiki’s order is: make sure Vaultwarden is stopped, replace each file or directory in the data folder with its backed-up version, and, when you restore a file made by .backup or VACUUM INTO (which includes vaultwarden backup), first delete any existing db.sqlite3-wal, because a stale log “could potentially result in database corruption”. It also says to move or keep a copy of your original data in case the backup turns out not to work, so do that first. The commands below follow that order, for a stack started from your normal compose file. They also remove db.sqlite3-shm, which the wiki says you need not restore. Use /backup/db.sqlite3 instead of db_*.sqlite3 for a file made by sqlite3 .backup, and point the second -v at the dated folder of the backup you want:
Not run in our lab.
docker compose stop vaultwarden
docker run --rm -v vaultwarden-backup_vw-data:/data -v /srv/vw-backup/20261025-031000:/backup:ro --entrypoint sh docker:28.5.2-cli -c "set -e; rm -f /data/db.sqlite3-wal /data/db.sqlite3-shm; tar xzf /backup/vw-files.tar.gz -C /data; cp /backup/db_*.sqlite3 /data/db.sqlite3"
docker compose up -d --wait vaultwardenFor the cold copy of the whole folder, extract vw-data.tar.gz instead and skip the cp, but keep the rm: the archive may hold no -wal (SQLite normally deletes it on a clean close, though ours stayed after docker stop), and a stale one left in the volume would then stay beside the restored database. If the archive does hold a -wal, it is the matching one, and the wiki says a straight copy of db.sqlite3 and its -wal must be restored as a pair. Then check the result as in the list above. You also need your compose file and .env (the admin hash lives there); the Compose guide covers them.
What a missing file costs
To see which files matter, the lab restored the M1 backup three more times, each with one thing left out of the tar. These controls ran the 11 checks that do not need the CLI. The failures were the same in all three runs.
| Left out of the restore | Checks passed (of 11) | What failed | What still worked |
|---|---|---|---|
rsa_key* |
9 | An access token issued before the backup was rejected with HTTP 401, and Vaultwarden had generated a new rsa_key.pem |
Login, 225 of 225 items, attachments, the Send and the admin setting |
attachments/ |
10 | 0 of 3 attachment downloads (HTTP 404 each) | Login and all 225 items; the items still listed the three files |
config.json |
10 | The saved admin setting was gone | Everything else, including the old token |
The rsa_key result matches the wiki: without the key, signed-in users are logged out and sign in again, and the vault itself is fine. The wiki adds that open invitation links stop working; we did not test that. The attachments result is the dangerous one: the vault looks complete until someone opens a file. We ran no control for sends or icon_cache.
Schedule it
The lab ran each command once, by hand, from one script. Nothing in this section was run in our lab.
The wiki suggests a cron job at least daily. Below is one script that combines the lab’s M1b steps. Save it as /usr/local/bin/vw-backup.sh, make it executable and run it as root. The container and volume names are ours: take yours from docker ps and docker volume ls. What the script does:
- It writes each run into its own folder under
/srv/vw-backup, named by UTC date and time, and adds.partialto the name until the run has finished. A folder that still ends in.partialis an incomplete backup. - It sets
umask 077andchmod 700on/srv/vw-backup, so only root can read the files. The tar holdsrsa_key.pemandconfig.json, which the wiki calls sensitive (see the encryption notes below). - It deletes folders older than 14 days, as its last step, so a failing job never prunes.
-mtime +14counts whole 24-hour periods from the folder’s last change.
Not run in our lab.
#!/bin/sh
set -eu
umask 077
CONTAINER=vaultwarden-backup-vaultwarden-1
VOLUME=vaultwarden-backup_vw-data
DEST=/srv/vw-backup
STAMP=$(date -u +%Y%m%d-%H%M%S)
mkdir -p "$DEST"
chmod 700 "$DEST"
mkdir "$DEST/$STAMP.partial"
docker exec "$CONTAINER" /vaultwarden backup
docker run --rm -v "$VOLUME:/data" -v "$DEST/$STAMP.partial:/backup" --entrypoint sh docker:28.5.2-cli -c 'set -e; umask 077; cd /data; cp db_*.sqlite3 /backup/; rm db_*.sqlite3; tar czf /backup/vw-files.tar.gz attachments sends config.json rsa_key*'
mv "$DEST/$STAMP.partial" "$DEST/$STAMP"
find "$DEST" -mindepth 1 -maxdepth 1 -type d -name '20??????-??????' -mtime +14 -exec rm -rf {} +If one of attachments, sends or config.json does not exist yet, tar in the helper container prints an error for it and exits non-zero (Alpine’s tar exited with status 1 in the Compose guide’s check), set -e stops the script and the folder stays .partial; remove that name from the tar line. After such a failure the db_*.sqlite3 file has already been copied out and deleted from the data folder. A folder left as .partial is not removed by the last line; delete it by hand. The 14 days of copies are on the same machine, so they are not an offsite copy.
A systemd service and timer, in the same shape as the scheduling section of the restic vs Borg vs Kopia page. Save the first as /etc/systemd/system/vw-backup.service and the second as /etc/systemd/system/vw-backup.timer.
Not run in our lab.
[Unit]
Description=Vaultwarden backup
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/vw-backup.shNot run in our lab.
[Unit]
Description=Run the Vaultwarden backup every night
[Timer]
OnCalendar=*-*-* 03:10:00
Persistent=true
[Install]
WantedBy=timers.targetEnable the timer, run the service once by hand, and look at the folder and the log:
Not run in our lab.
sudo systemctl daemon-reload
sudo systemctl enable --now vw-backup.timer
sudo systemctl start vw-backup.service
sudo ls -l /srv/vw-backup
sudo journalctl -u vw-backup.serviceCron works too, and the wiki’s example uses it. Whichever you pick, restore a copy now and then. The wiki says to “run through the process of restoring from backup periodically”.
Offsite copies and encryption
The wiki says ideally at least one copy should be stored remotely, and warns against relying on VM or filesystem snapshots as the backup method. Our lab did not test snapshots. For the offsite copy, point a backup tool at /srv/vw-backup. The wiki names restic and rustic, and rclone for cloud storage. Our restic vs Borg vs Kopia comparison measured three such tools on a generated dataset with local repositories, and restored every file with a matching SHA-256. We never ran any of them on a Vaultwarden backup, over a network, or to cloud storage. Not tested in our lab.
One caution that is our reasoning, not a test. The wiki recommends restic or rustic for large attachments so they are not recopied on every backup. The script above writes a new compressed vw-files.tar.gz each night, and a deduplicating tool has little to reuse between two such archives. If your attachments are large, consider backing up the attachments folder itself with the tool and keeping the database copy and small files in the nightly folder.
On encryption, here is what we checked, and where:
- The vault items are encrypted by the client before they reach the server. Bitwarden’s security white paper says vault data is protected with end-to-end encryption, and its encryption page says the servers “are only used for storing encrypted data”. Bitwarden’s vault data page lists what is encrypted: item names, notes, usernames, passwords, URIs, custom fields, folder and collection names, Send contents, and attachments including their file name and contents. The white paper says an attachment’s file name and size are encrypted with the item’s cipher key and its contents with a separate attachment key, so we do not list attachment metadata as plaintext. Those pages describe Bitwarden’s own clients and service; Vaultwarden speaks the same API, and we did not read its code for this. Our lab’s items were encrypted by the lab’s own client code, written to Bitwarden’s published scheme, and the official CLI decrypted all of them. We did not inspect the database for plaintext, and we did not check a real client’s output.
- Not everything in the backup is covered by that. Bitwarden’s administrative data page lists data the service itself can read, such as the account email address and a device ID. The Vaultwarden wiki says the database stores “database entries, user/org/device metadata”.
config.jsonandrsa_key.pemare separate files and are not vault items at all (next two bullets). - The wiki still recommends encrypting backups. It says an extra layer of encryption “would generally be a good idea”, especially if the backup includes config data such as the admin token, and that you might skip it if you are confident the master passwords are strong.
config.jsonholds plaintext secrets. Per the wiki: “admin token, SMTP credentials, etc.” Ours held a single 48-byte setting, and our admin token was set in the environment, not on the admin page, so check what yours contains.rsa_key.pemis sensitive. The wiki says it could be used to forge login sessions. Forging a vault session needs more knowledge, such as UUIDs from a copy of the database, and data obtained that way is still encrypted. But “Admin panel login sessions, however, could be forged easily” when the admin page is enabled; that gives no access to vault data but allows actions such as deleting users or removing 2FA.- We would encrypt the whole backup rather than decide field by field. That is our advice, not a test. Your
.envholds the admin hash, so treat any copy of it the same way. - Backup tools encrypt for you. The feature table on the comparison page, researched from each tool’s documentation, shows encryption always on in restic and Kopia, and a required choice at
initin Borg. We did not test restoring a Vaultwarden backup from any of them. A plainrclone copyuploads the files as they are; rclone has a separate crypt remote for encryption, which we did not test.
What we did not test
- Accounts with Argon2id. The seeded account used PBKDF2-SHA256 with 600,000 iterations, the setting Bitwarden’s white paper describes for account creation. The white paper says users can change to Argon2id later. We did not test an Argon2id account, so check which KDF yours uses.
- Real clients. No web vault, browser extension or mobile app. The restore checks use the API, our script’s client and the official CLI. A browser needs HTTPS in front of the server, which this lab stack did not have.
- Databases other than SQLite. MySQL and PostgreSQL need their own dump, per the wiki.
- Online backups during writes, a naive copy of an idle vault, and a live copy of the whole folder (
db.sqlite3with its-wal), which is what a file-level backup tool would take (see above). - Restoring over existing data, including the wiki’s
-walstep. The commands in that section are marked. - Official Bitwarden self-host. We ran Vaultwarden only.
- Other vault sizes and versions. One vault of 225 items and 11.6 MB of attachments. Larger vaults, and Vaultwarden versions before 1.37.4, are untested, as are ARM hosts.
- Two-factor login, emergency access, SMTP and
icon_cache. - Scheduling, retention, file permissions, offsite upload and encrypted archives. The script, units and permissions advice are unrun.
- The lab’s toolbox container. It mounts the Docker socket so the script can stop the live container and create the restore projects. It is lab-only and not advice; a backup job on your server does not need it.
The stack we backed up
Vaultwarden 1.37.4 alone on a named volume, with signups off and a healthcheck, and no reverse proxy: a backup copies the data folder, which is the same behind Caddy. The lab compose file carries a DOMAIN of vault.example.com. For the Caddy version, see the Compose guide.
# Verified compose for Vaultwarden backup and restore, tested 2026-10-22 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: AMD EPYC 9V74 80-Core Processor, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# 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-backup.json (schema 3).
# Lab difference: Adds a `toolbox` container (docker:28.5.2-cli, the Docker socket, the live data volume and a backups volume) that runs the backup commands, stops and starts the live container and creates the separate restore projects. A backup host would run these commands from cron; the socket is there only so the lab can drive docker.
# Companion env file: vaultwarden-backup.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In vaultwarden-backup.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), pinned, with the data folder in one named volume.
# This is the stack the backup test backs up. It has no reverse proxy: a backup copies the data folder, and the data folder is the same
# with or without Caddy in front. (The web vault needs HTTPS in a browser; see the Vaultwarden Docker Compose guide for the Caddy version.)
name: vaultwarden-backup
services:
vaultwarden:
image: vaultwarden/server:1.37.4 # sha256:efb3cde962015fcc036b2ea625242248611943b27212ebf4392841fb30fad055 (version 1.37.4, from label:org.opencontainers.image.version)
restart: unless-stopped
environment:
# The public URL of the vault.
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: db.sqlite3 (+ -wal/-shm while running), attachments/, sends/, rsa_key.pem, config.json. This is what you back up.
- vw-data:/data/
ports:
# Bound to localhost only: let the reverse proxy on the host reach it.
- "127.0.0.1:8080:80"
# The image already defines a check (/healthcheck.sh, every 60 s). This keeps that script and tightens the timing.
healthcheck:
test: ["CMD", "/healthcheck.sh"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
start_interval: 1s
volumes:
vw-data:# 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.4 /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=Where to run it
- Hardware. Vaultwarden with Caddy idled at 25 MiB and peaked at 70 MiB under load (medians of 3 runs) in our lab. The highest reading in any run was 72 MiB. Plan for at least 1 GB of RAM, sized from the highest run.
- VPS. The smallest plan that runs this comfortably has 1 GB of RAM, plus disk for your data and the images. Plan for about 530 MB for the images (Docker 29 and later with the containerd image store, the default on fresh installs; 366 MB with the older overlay2 store).
- 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 in any of our runs (under load or during startup, not the median) 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. Disk for images: Docker Engine 29 and later uses the containerd image store on fresh installs, which keeps each image twice (as downloaded and unpacked); older installs use overlay2, which keeps it unpacked only. We plan for the larger figure: unpacked plus compressed size, plus 10%, rounded up. Your data volumes and growth come on top.
Related
- The stack these backups belong to, with HTTPS, the hashed admin token and an earlier, smaller restore drill: Vaultwarden Docker Compose.
- Backup tools for the offsite copy, measured on one dataset: restic vs Borg vs Kopia. The same habit of restoring into a fresh stack and checking file hashes: Nextcloud and Immich.
- How the lab takes medians and ranges over runs: methodology.
- Sources read on Oct 25, 2026: the Vaultwarden wiki page Backing up your vault, the 1.32.1 release notes, the 1.37.4 Dockerfile, SQLite’s WAL and How to corrupt an SQLite database file pages, and Bitwarden’s security white paper, encryption, vault data, administrative data and Backup Server Data pages.
Update log
- First published. Vaultwarden 1.37.4, three runs on Oct 22, 2026: an online SQLite backup, the built-in `vaultwarden backup` and a cold copy each restored into a fresh stack and passed 15 of 15 checks; a plain copy of db.sqlite3 during writes lost items; three missing-file controls. The wiki and SQLite statements were read on Oct 25, 2026. Scheduling and offsite copies were not run.