StackNotes

Vaultwarden backup and restore: the 250 KB file that restores an empty vault

7 November 2026 · Nysa Dimitriou · 9 min read

The short version: Vaultwarden runs SQLite in WAL mode, so db.sqlite3 on its own can be almost empty while the instance works perfectly. Copy db.sqlite3 together with db.sqlite3-wal, or take an online backup through SQLite and check that the copy contains users. Take attachments/ and rsa_key.pem as well. Then restore the backup somewhere and log in, because a backup nobody has opened is a rumour.

My password manager is a container on the same box as everything else, which means one bad backup script stands between me and a very annoying afternoon. I have written about backing up databases before, and I thought I knew the shape of this one: one SQLite file, one attachment folder, one key. Then I drilled it properly and the first restore came back empty.

The test ran on a devbox with Docker 27.5.1 and the vaultwarden/server:1.34.3 image, which reports itself as Vaultwarden 2025.6.0. I created an account through the real registration API, added folders, items and attachments through the authenticated API, and then restored the data in eight different ways. Every number below comes from that run; the full log is at the bottom.

What is actually in the data directory

A working Vaultwarden data directory at 40 items looked like this:

FileBytesWhat it is
db.sqlite3249,856the database, as of its last checkpoint
db.sqlite3-wal1,256,632writes not yet folded into the database
db.sqlite3-shm32,768shared-memory index for the WAL
rsa_key.pem1,675signs login and download tokens
attachments/126three test files, 42 bytes each

There are other things in a real deployment: a sends/ directory if you use Bitwarden Send, config.json if you have written settings, an icon cache. None of them are the vault. The vault is the database plus the attachment blobs, and the database is not one file while the service is running.

That table is the whole problem. The file everyone copies is the one that looks like the vault and is not, at that moment, the whole vault.

The backup that restores an empty vault

I copied db.sqlite3 while the container was running, the way a hundred tutorials tell you to. The copy was 249,856 bytes. I put it in a clean directory, started the same image against it, and the instance came up in 210 ms. /alive returned 200.

Then I tried to sign in, and there was no account. A direct count against the restored database returned users=0 ciphers=0 folders=0 attachments=0. The backup was a valid SQLite database with the right schema, the right tables, and none of my data.

The reason is in the journal mode. Vaultwarden sets SQLite to WAL, which means new writes go to db.sqlite3-wal and reach the main file only at a checkpoint. I measured that directly: from a checkpointed state, ten item creations moved the main file from 282,624 bytes to 282,624 bytes, and grew the WAL from 0 to 214,272 bytes. About 21 KB of WAL per new item, and the file everyone backs up did not move at all.

At 40 items the main file was 249,856 bytes and the WAL was 1,256,632. The file that looked like the database held 16.6% of the database’s bytes. On a longer-running instance with more changes, that ratio gets worse before it gets better: SQLite’s default checkpoint is around a thousand pages, so several megabytes of committed data can sit outside the main file at any moment. The instance in front of you works fine the whole time. So does the backup, until you open it.

Stopping the container does not save you

The obvious fix is to stop the service before copying. I tried it. docker stop took 283 ms and returned cleanly. Afterwards the files were exactly the same size: 249,856 bytes of database and 2,529,712 bytes of WAL, still sitting there. I copied the database alone again and restored it. Same result: 200 on /alive, zero users.

In this setup, the WAL was folded into the main file on the next start, not on shutdown. When I started the original container again, SQLite recovered the WAL and checkpointed it: the database became 282,624 bytes and the WAL went to zero. That is a real and useful behaviour, and it is also why a backup schedule that copies the file before the first start of the day can look fine for months and then fail on the day the box did not reboot.

So “stop it first” is not a fix for this database. It is advice from the era of databases that kept everything in one file. Vaultwarden does not, and the second file has the same name plus a suffix that backup tools happily skip.

Two ways to get a database you can actually restore

The first is to ask SQLite for a consistent copy while the service runs. The standard library can do it, no downtime and no shell tools:

python3 - "$DATA/db.sqlite3" "$DEST/db.sqlite3" <<'PY'
import sqlite3, sys
src = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)
dst = sqlite3.connect(sys.argv[2])
src.backup(dst)
dst.close()
src.close()
PY

On the 40-item vault that took 19 ms. The result was a single 262,144-byte file with integrity_check returning ok, all 40 items, both folders and the account. The restored instance started in 211 ms, issued a login token, and served 40 items through the API. It also served the attachment with a SHA-256 identical to the live file, which is the check most people skip.

The second is to copy db.sqlite3 and db.sqlite3-wal together. I tested that too, from a 100-item state, leaving out -shm: the restored copy passed integrity_check, listed 100 items, accepted the old login token, and downloaded the attachment with the same hash. It works. I still do not recommend it as the primary method, because the two files are copied at two different instants and nothing checks that they agree. My one run under concurrent writes produced a consistent snapshot, but that is one run, not a guarantee.

What never works is the database alone. That is the whole finding, and it cost a restore to learn.

A script that does the boring parts

I wrote the version I now use: vaultwarden-backup.sh. It takes the data directory and a backup root, and it does five things in order.

  1. Copies the database through SQLite’s backup API, so the WAL is included.
  2. Copies attachments/ if it exists.
  3. Copies rsa_key.pem.
  4. Copies sends and config.json when present.
  5. Opens the copy and refuses to keep it unless integrity_check says ok and the users table is not empty.

The last step is the one I care about. A backup script that cannot fail loudly is a backup script that will hand you an empty database on the worst day of your year. The script writes a SHA-256 manifest, packs everything into a timestamped tarball, and removes the staging directory. Run against the 110-item vault it produced a 22,090-byte archive in 58 ms, with integrity ok; users=1 ciphers=110 folders=2 attachments=3 devices=1.

That check has a deliberate sharp edge: an instance with no users is refused. If you are backing up a fresh install before the first account exists, the script will stop. Edit the check or skip that backup; do not weaken it for a live vault.

Attachments are not in the database

Restoring the database alone gave a working instance that listed all 40 items, including the three with attachments. Requesting one returned HTTP 404, because the blob lives in attachments/<cipher-id>/<attachment-id> and I had not copied it. The attachment was still listed on the item. That is the sort of failure a backup test catches and a backup log does not.

Use rsync -a or cp -a on the whole directory. In my test the three attachments were 42 bytes each; in a real vault they are the largest thing you own, and they are the part most likely to be excluded by a glob that only matches *.sqlite3.

The key, and the sessions you will lose

rsa_key.pem signs the tokens the server hands out. It is not the vault’s encryption key; your master password and the users’ own keys do that. It is the thing that makes an existing browser session valid.

Restoring the database and attachments without it produced a new key, a working login, and HTTP 401 for the token issued before the restore. Nobody loses data. Everybody has to sign in again, on every device, at the moment you are already having a bad day. Copy the file, keep it out of the same public bucket as the database if you can, and treat it as a secret: with the database, it is enough to mint sessions.

There is a second, subtler version of the same effect. The token payload carries an issuer built from your DOMAIN setting, in the form http://host:port|login. A copy restored under a different domain rejected the old token with 401 even though the key was identical; a copy of the same data restored under the original domain accepted it with 200. If you restore to a new hostname, expect every client to re-authenticate. That is not corruption. It is the server being precise about who issued the session.

The restore drill, step by step

A backup is a hypothesis until you restore it. This is the sequence I ran, and the one I would keep in a runbook.

# 1. unpack somewhere that is not the live data directory
mkdir -p /tmp/vw-restore && tar -C /tmp/vw-restore -xzf vaultwarden-20261107T141034Z.tar.gz

# 2. check the archive's own hashes
cd /tmp/vw-restore && sha256sum -c SHA256SUMS

# 3. start a second instance on another port, same DOMAIN and image
docker run -d --name vw-restore -p 8081:80 \
  -e DOMAIN=https://vault.example.com \
  -v /tmp/vw-restore:/data vaultwarden/server:1.34.3

# 4. prove it serves the vault
curl -fsS http://127.0.0.1:8081/alive
# log in through the API or the web vault, then:
curl -sS http://127.0.0.1:8081/api/ciphers \
  -H "Authorization: Bearer $TOKEN" | jq '.data | length'

# 5. compare one attachment byte for byte
sha256sum /tmp/vw-restore/attachments/<cipher-id>/<attachment-id>

In my run the restored instance was answering in 540 ms, listed 110 items, and the attachment hash matched the live file. That is the point at which I would stop calling it a backup and start calling it a restore.

The swap itself is the easy part: stop the old container, move the old data directory aside, move the restored one into place, start, and run the same three checks. Keep the old directory until the new one has been up for a day. Disk is cheaper than a second bad afternoon.

What this drill does not prove

The vault had 110 synthetic items with random test strings, not a real encrypted vault, and the attachments were 42 bytes. Backup time scales with the database and, much more, with attachments; 58 ms here says nothing about 40 GB of family photos.

Everything ran on SQLite. Vaultwarden also supports PostgreSQL and MySQL, and the file layout is different there; the backup API step would be pg_dump instead. I did not test a restore into an older Vaultwarden release, and I would not expect it to work: schema migrations move forward.

This is also only the local half of a backup plan. A tarball on the same disk as the container is a copy, not a backup. The 3-2-1 argument and a tested offsite copy are in the self-hosted backup stack, and the same restore-first discipline shows up in the Immich drill. Neither of those covers Vaultwarden’s WAL, which is why this post exists.

One more limit worth writing down: the online backup is a snapshot. Writes that land after it are not in it. If your backup runs nightly, you can lose a day of new passwords; if that matters, run it more often and keep the last few archives.

The rule

Copy the database and its WAL together, or take an online backup; copy the attachment directory and the key; then open the result, log in, and count what you find. The first restore I ran had a valid database, a healthy service and none of my data, and only the count told me.

The script and the full test log are the two files I would keep: vaultwarden-backup.sh and vaultwarden-backup-drill.txt, the latter with every command and every number from the run above.

Full drill log: vaultwarden-backup-drill.txt — 126 lines, every command and result in this post.