Homelab
Proof

U3 / Backups and restore

Vaultwarden restore drill in 29.1 s, and live SQLite copies

Tested on Vaultwarden 1.37.4

Test result

A stopped-container restic backup of the Vaultwarden volume restored into a brand-new VM and all 2,000 items decrypted correctly through the API, 29.1 s after the fresh VM started booting (one run, Docker preinstalled, backup disk attached at boot). In 40 copies of a live 32,000-item database under write load, a copy of db.sqlite3 without its -wal file failed PRAGMA integrity_check 10 of 10 times, while copying the whole directory with restic or rsync, and sqlite3 .backup, each passed 10 of 10. An earlier run in which the database kept growing to about 700,000 items did produce failed restic and rsync copies, so database size may matter; use sqlite3 .backup or stop the container.

Line drawing: a fire-drill style alarm bell above an empty server and a filled backup drive with a cable reconnecting them.

A backup you have never restored is a guess. This drill backs up a real Vaultwarden vault, destroys the machine, and rebuilds it from nothing but a second virtual disk and a compose file, then proves the vault is back by logging in through the API and decrypting every item.

The drill, clocked

Restore drill, every step clocked from the host
StepWhatWall timeResult
01Source: compose up, wait for /alive5.9 sok
02Source: create account through the API1.1 sok
03Source: create 2,000 login items through the API2.9 sok
04Source: read all items back and decrypt1.3 sok
05Backup: stop container, restic init + backup + check, start, unmount6.2 sok
06Destroy the source VM (poweroff, delete overlay)2.3 sok
07Boot a fresh VM with the backup disk attached12.0 sok
08Mount disk, install bzip2 and restic 0.19.17.7 sok
09Pull image, create the (empty) volume4.6 sok
10restic restore into the volume1.6 sok
11PRAGMA integrity_check on the restored database0.3 sok
12compose up, wait for /alive1.7 sok
13Log in and verify all 2,000 items through the API1.2 sok
Recovery time, fresh VM to verified data (steps 07 to 13, including the integrity check)29.1 s

Single run (the drill destroys its own VM, so it was run once). VM 4 vCPU, 3 GB RAM. The fresh VM boots from a base image that already has Docker and the compose plugin, and the backup disk is attached at boot, so neither provisioning nor moving the disk is in the total. Includes downloads of the restic binary and the Vaultwarden image over the VM's network, and the integrity check (step 11).

What the vault held: one account and 2,000 login items created through Vaultwarden’s own API, encrypted client-side with the account’s master key (the test script implements the Bitwarden key derivation and encryption in Python). The Docker volume was 59,591,243 bytes on disk. The backup target was a second virtual disk mounted at /mnt/backup; no cloud account was involved.

After the restore, vwclient.py verify logged in with the master password, called /api/sync, and for each of the 2,000 items checked the MAC and compared the decrypted username, password, URL and notes against the values the seed produced. The result: 2,000 of 2,000 matched, 0 errors. The raw output is in raw/api-verify-restored.txt.

The recovery time covers a boot, a restic download, an image pull, a restore, the integrity check and the API read-back. It starts with a VM that already has Docker and the compose plugin (the lab base image) and a backup disk attached at boot, so installing Docker and moving a disk are not in it. In this run most of it is fixed overhead rather than data: the restore itself took 1.6 s. This is one run; an earlier run of the same script, whose raw output was replaced by this one, took 36.7 s for the same steps without the integrity check (about 37.1 s with it), so expect a spread of several seconds. A vault that is 60 MB restores quickly everywhere; a volume with large attachments will not.

What the drill does not make faster

Steps 07 and 08 (booting a machine, installing the tool) dominate. Keeping the restic binary on the backup disk, or in your base image, removes the download. That change was not measured here.

The written checklist

This is the checklist behind the drill; steps 2 to 9 are what the script did, in this order. Copy it and write down your own timings the first time you run it.

  1. Before anything breaks: confirm the backup disk is mounted, the restic repository password is stored somewhere that is not the machine being backed up, and restic check returns no errors. restic check verifies the repository structure; add --read-data to read and verify every pack, which the drill did not do.
  2. Provision a replacement host with Docker and the compose plugin.
  3. Attach the backup disk and mount it.
  4. Install the same restic version (0.19.1 here).
  5. Pull the image and create the empty volume: docker compose -p drill up --no-start.
  6. Restore into the volume path: restic restore latest --target /. The snapshot stores absolute paths, so the target is /. As root this writes straight into the live filesystem, so on a host that holds anything else, add --include /var/lib/docker/volumes/drill_vw_data/_data (not used in the drill, where the new VM held nothing else) so that only the volume path is written.
  7. Run PRAGMA integrity_check; on db.sqlite3 before starting the container.
  8. Start the stack with the same compose file and wait for /alive.
  9. Log in as a real user and read the data back. Do not stop at “the container is healthy”.
  10. Write down the elapsed time and what slowed you down.

The compose file used (signups were enabled only so the test could create its account through the API; the stack in the Vaultwarden guide has them off):

lab/tests/restore-drill/compose.yml
Show the full file
# Compose used by the restore-drill test. SIGNUPS_ALLOWED is true only so the test can create its account through the API.
services:
  vaultwarden:
    image: vaultwarden/server:1.37.4
    restart: unless-stopped
    environment:
      SIGNUPS_ALLOWED: "true"
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - vw_data:/data
volumes:
  vw_data:

The backup command in step 05 was, in order: docker compose -p drill stop, restic init, restic backup /var/lib/docker/volumes/drill_vw_data/_data, docker compose -p drill start, restic check. With the container stopped, nothing writes during the copy. The Vaultwarden wiki recommends sqlite3 .backup for a database that may be in use, and says that if you copy db.sqlite3 directly you must restore it together with its -wal file.

What a backup of a live SQLite file did

The drill above used a stopped container. The more common mistake is to point a backup tool at the data directory of a running Vaultwarden. This part of the test did that on purpose, under load.

Setup: the database was filled to 30,000 items by script (32,000 with the drill’s 2,000), the container was stopped cleanly and the data directory copied aside as a pristine copy. Before every trial that pristine copy was restored over the volume and the container started, so each of the 40 trials began with the same 32,000-item database. Then six threads posted new login items for 20 seconds (as fast as the server accepted them), and three seconds in the data was copied four ways, ten trials each. The method order was shuffled within every round of four (seed 7, raw/order.txt). Vaultwarden’s database was in WAL mode (PRAGMA journal_mode returned wal in raw/db-info.txt). The pristine copy holds a 45 MB db.sqlite3 next to a 869 MB db.sqlite3-wal file, because the WAL file kept its size after the bulk load; a real vault that was never bulk-loaded would have a much smaller one. Each copy was opened with the SQLite CLI and checked with PRAGMA integrity_check. The restic copy was a backup into a fresh repository, timed without the restore that followed it.

Copying a live Vaultwarden SQLite database under write load
Capture methodTrialsintegrity_check = okCopy has fewer rows than were acknowledged when it startedWAL file present in the copy before opening itCapture timeWrites acknowledged during the copy (median)
restic backup of the live volume directory1010/100/1010/104304 ms (3405-4832)4064
rsync of the live directory, throttled to 20 MB/s1010/100/1010/107870 ms (6224-8966)6216
cp of db.sqlite3 alone (no -wal file)100/100/100/1038 ms (31-52)41
sqlite3 .backup (online backup API)1010/100/100/1017605 ms (17570-17674)14213

Before every trial the data directory is replaced by the same pristine copy (30,000 items, taken after a clean stop), the container is started, then 6 threads post new login items for 20 s; the copy starts 3 s in. Method order is shuffled in every round of 4 (seed 7, raw/order.txt). Capture time covers the copy only (for restic, the backup into a fresh repository; the restore into the check directory is not included). Rows are counted in the copy and compared with the rows present at trial start plus writes acknowledged when the copy began. 10 trials per method: a clean result in 10 trials does not prove a method is safe, and an unclean one is the result.

  • Copying the live directory with restic produced a database that passed the check in 10 of 10 trials, in a copy that took 3.4 to 4.8 s while about 4,000 writes were acknowledged during it. The copy contained the -wal file every time.
  • A throttled rsync of the live directory (20 MB/s) also passed 10 of 10; it took 6.2 to 9.0 s.
  • Copying db.sqlite3 alone, without its -wal file, failed in all 10 trials, with errors such as “database disk image is malformed” and b-tree page errors. The copy took about 40 ms; the likely reason it fails is that recent changes still live in the WAL file, which this copy leaves behind (not examined further).
  • sqlite3 .backup passed all 10 and took 17.6 s every time, against about 4 s for the restic copy. The SQLite backup docs say the backup usually restarts when another process writes to the source; the 17.6 s is consistent with it waiting out the rest of the write burst, but restarts were not counted.

Per-trial detail is in sqlite-integrity-detail.json and raw/. The row count in each copy never fell below the rows present at trial start plus writes acknowledged when the copy began, except that one db-file-only trial has no acknowledged-write count (the counter file was read while being rewritten) and is left out of that comparison. Whether the table data itself was damaged in the failing copies was not examined beyond integrity_check.

The earlier run said otherwise

The first version of this test did not reset the database between trials, so it grew from 32,000 items to about 710,000 over the 40 trials, and the four methods ran one after another, each on a larger database than the last. That run reported restic failing the check in 7 of 10 trials and rsync in 2 of 10, with “row N missing from index” errors. Every one of those failures came after the database passed about 80,000 items; the three clean restic trials were the first three, at 32,000 to 65,000 items. When size and order were taken out, the restic and rsync failures did not reproduce in 10 trials each. The raw output of that run was replaced and is not kept, and the test did not repeat these trials at 80,000 items or more with a reset, so it cannot say whether size, the larger WAL or something else caused those failures. Do not read either result as “a live directory copy is safe”.

Treat this honestly in both directions. Ten clean restic and rsync trials at one database size, on a small VM, do not mean a live copy is usually fine: the earlier run found failures on a larger database, a real vault takes far fewer writes than this load, and a clean run proves nothing about the next one. The configurations that never failed in either run were the stopped container (one drill run) and .backup (the method the Vaultwarden wiki recommends). The only method that failed every time in both runs was copying db.sqlite3 without its -wal file.

What went wrong along the way

  • POST /identity/accounts/register returned 404 on 1.37.4. Registration goes through /identity/accounts/register/send-verification-email and then /identity/accounts/register/finish; creating items also required encryptedFor set to the user id. Both were found in the Vaultwarden source.
  • Earlier attempts of the load trials had harness bugs (a permission error made some copies look empty). Those runs were discarded and the test was rerun from the start. After the run described above showed a confound (a database growing between trials, see “The earlier run said otherwise”), the trials were rerun a second time with a reset before every trial; the numbers above come from that final run only.
  • In the final run the harness read the acknowledged-writes counter while it was being rewritten once and got an empty value, so one trial has no acknowledged-write count at its start.

What this test did not cover

  • A single drill run. The drill destroys its own VM, so the 29.1 s total is one measurement, not a median; the earlier run’s 36.7 s (see above) shows the spread.
  • A hard failure. The source VM was powered off cleanly and its disk deleted; the backup disk was unmounted before that. A backup disk that was never unmounted, or corrupted media, was not tested.
  • Attachments, Sends, organizations, 2FA, and the admin page’s config.json. Only the database and rsa_key.pem were in the volume.
  • Remote or cloud repositories. Their restore speed will depend on bandwidth.
  • The effect of a live copy on other tools (borg, kopia, plain cp -a of the whole directory) beyond the four methods listed, the effect at lower write rates, and at database sizes above 32,000 items with a reset between trials.
  • Whether Vaultwarden clients behave correctly after restoring a backup older than their last sync; only the API read-back was checked.

Sources and how this was made

lab/tests/restore-drill/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# Test restore-drill: restic backup of a Vaultwarden 1.37.4 volume to a second virtual disk, destroy the VM,
# restore into a fresh VM and prove the data through the API; then copy a live SQLite DB under write load.
set -uo pipefail
source "$(dirname "$0")/../_common/lib.sh"
ID=restore-drill; D="$ROOT/lab/tests/$ID"; E="$ROOT/evidence/tests/$ID"; RAW="$E/raw"
DISK="$LABDIR/h-$ID-backup.qcow2"; N=2000
rm -rf "$RAW"; mkdir -p "$RAW"; : > "$RAW/steps.tsv"
cleanup() { vm down h-$ID-1; vm down h-$ID-2; rm -f "$DISK"; }
trap cleanup EXIT
step() { local n=$1; shift; local s e rc; s=$(date +%s%3N); "$@"; rc=$?; e=$(date +%s%3N); printf '%s\t%d\t%d\n' "$n" $((e-s)) $rc >> "$RAW/steps.tsv"; return $rc; }
host_env "$RAW"
qemu-img create -q -f qcow2 "$DISK" 10G
DRV="-drive file=$DISK,if=virtio,format=qcow2"
RESTIC='sudo apt-get install -y -qq bzip2 >/dev/null 2>&1; cd /tmp && curl -fsSL -o restic.bz2 https://github.com/restic/restic/releases/download/v0.19.1/restic_0.19.1_linux_amd64.bz2 && bunzip2 -f restic.bz2 && sudo install -m755 restic /usr/local/bin/restic'
export_env='export RESTIC_PASSWORD=lab-only-not-a-secret RESTIC_REPOSITORY=/mnt/backup/repo'

echo "== phase 1: source VM" 
vm up h-$ID-1 $DRV || exit 1
gssh 'sudo apt-get install -y -qq sqlite3 python3-cryptography jq >/dev/null 2>&1; sudo mkdir -p /srv/work && sudo chown lab:lab /srv/work' 
vm scp-to "$D/compose.yml" /srv/work/; vm scp-to "$D/vwclient.py" /srv/work/; vm scp-to "$D/load-trials.sh" /srv/work/
gssh "$RESTIC"
gssh 'sudo mkfs.ext4 -q -L drillbackup /dev/vdb && sudo mkdir -p /mnt/backup && sudo mount /dev/vdb /mnt/backup'
step 01-compose-up-source gssh 'cd /srv/work && docker compose -p drill up -d >/dev/null 2>&1; for i in $(seq 1 90); do curl -fs http://127.0.0.1:8080/alive >/dev/null && exit 0; sleep 1; done; exit 1'
step 02-create-account gssh 'cd /srv/work && python3 vwclient.py register' | tee -a "$RAW/api-register.txt"
step 03-seed-items gssh "cd /srv/work && python3 vwclient.py seed $N" | tee "$RAW/api-seed.txt"
step 04-verify-source gssh "cd /srv/work && python3 vwclient.py verify $N" | tee "$RAW/api-verify-source.txt"
gssh 'sudo sqlite3 /var/lib/docker/volumes/drill_vw_data/_data/db.sqlite3 "select count(*) from ciphers; select count(*) from users;" ; sudo du -sb /var/lib/docker/volumes/drill_vw_data/_data' > "$RAW/source-db-facts.txt"
step 05-backup-stop-restic-start gssh "$export_env"'; cd /srv/work; docker compose -p drill stop >/dev/null 2>&1; sudo -E restic init >/tmp/init.txt 2>&1; sudo -E restic backup /var/lib/docker/volumes/drill_vw_data/_data 2>&1 | tail -8 > /tmp/backup.txt; docker compose -p drill start >/dev/null 2>&1; sudo -E restic check 2>&1 | tail -3 > /tmp/check.txt; sudo -E restic snapshots > /tmp/snap.txt; sync; cat /tmp/init.txt /tmp/backup.txt /tmp/check.txt /tmp/snap.txt; sudo umount /mnt/backup'  | tee "$RAW/restic-source-backup.txt"
step 06-destroy-source-vm vm down h-$ID-1
ls "$LABDIR"/run-h-$ID-1.qcow2 > "$RAW/vm1-overlay-after-destroy.txt" 2>&1
host_env "$RAW/../raw-tmp" ; rm -rf "$RAW/../raw-tmp"

echo "== phase 2: fresh VM"
step 07-boot-fresh-vm-with-disk vm up h-$ID-2 $DRV
step 08-mount-disk-install-restic gssh "sudo mkdir -p /mnt/backup /srv/work && sudo mount /dev/vdb /mnt/backup && sudo chown lab:lab /srv/work && $RESTIC"
vm scp-to "$D/compose.yml" /srv/work/; vm scp-to "$D/vwclient.py" /srv/work/; vm scp-to "$D/load-trials.sh" /srv/work/
gssh 'sudo apt-get install -y -qq sqlite3 python3-cryptography jq >/dev/null 2>&1' # drill tooling for verification only, not clocked
gssh 'docker ps -a --format "{{.Names}}"; docker volume ls -q' > "$RAW/vm2-before-restore.txt" 2>&1
step 09-pull-image-create-volume gssh 'cd /srv/work && docker compose -p drill up --no-start 2>&1 | tail -3'
step 10-restic-restore gssh "$export_env"'; sudo -E restic restore latest --target / 2>&1 | tail -3' | tee "$RAW/restic-restore.txt"
step 11-integrity-check-restored-db gssh 'sudo sqlite3 /var/lib/docker/volumes/drill_vw_data/_data/db.sqlite3 "PRAGMA integrity_check; select count(*) from ciphers;"' | tee "$RAW/restored-db-check.txt"
step 12-compose-up-restored gssh 'cd /srv/work && docker compose -p drill up -d >/dev/null 2>&1; for i in $(seq 1 90); do curl -fs http://127.0.0.1:8080/alive >/dev/null && exit 0; sleep 1; done; exit 1'
step 13-verify-through-api gssh "cd /srv/work && python3 vwclient.py verify $N" | tee "$RAW/api-verify-restored.txt"
gssh 'curl -s http://127.0.0.1:8080/api/version' > "$RAW/vw-version.txt"

echo "== phase 3: live SQLite copy under write load"
gssh 'bash /srv/work/load-trials.sh' > "$RAW/load-trials-stdout.txt" 2>&1
vm scp-from /srv/trials-raw/. "$RAW/"
guest_env "$RAW" 'restic version; sqlite3 --version; docker --version; docker image inspect vaultwarden/server:1.37.4 --format "{{.RepoTags}} {{.Id}}"'
python3 "$D/summarize.py" "$RAW" "$E"

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