A password manager restored halfway is worse than one that is lost: it looks complete. The accounts are there, the entries are there, and nobody notices a thing until the day someone types their master password and nothing decrypts.
This guide covers the whole chain for Vaultwarden: what a backup has to contain besides the database, why copying a SQLite file while it is open is not a backup, a script that cannot report success on a truncated archive, a restore into a throwaway container where you actually open the vault, and the queries that tell you a restored vault is still a vault. It needs only sqlite3, tar, docker and the AWS CLI.
The vault is not in the database
Vaultwarden knows none of your passwords. Every entry is encrypted in the browser before it is sent, and the server only stores the encrypted blob, in the data column of the ciphers table. The master password never leaves the user's machine.
What the server does keep is each account's encryption material, in the users table:
-
akey, the account's symmetric key, itself encrypted with a key derived from the master password; -
private_keyandpublic_key, the RSA pair used for sharing.
Hence the consequence that makes this subject different from an ordinary application database: a database restored without that material hands back no readable secret. The accounts are there, the entries are there, everyone types their master password, and nothing decrypts, permanently, because the missing key exists nowhere else. A partial export, a truncated column, a users table restored from a backup older than ciphers: in all three cases the vault looks complete and it is lost.
What sits beside the database
Vaultwarden's data directory (/data in the container) holds what the database does not:
| Content | What it is |
|---|---|
rsa_key.pem |
the key that signs session tokens |
attachments/ |
the attachments of your entries, encrypted as well |
sends/ |
the files behind Bitwarden Sends |
icon_cache/ |
site favicons, a cache, nothing to back up |
config.json |
the settings made from the admin page |
rsa_key.pem is not the vault's key: it signs tokens. Lose it and Vaultwarden makes another one at start-up, everyone logs in again, and the vaults stay readable. Attachments, on the other hand, are referenced from the database: a database restored alongside an attachments/ tree taken at a different hour gives you entries pointing at files that are not there.
A Vaultwarden backup is therefore two pieces taken together: the database and the data directory.
Two installs, two ways to back up
SQLite, the default install
Without DATABASE_URL, Vaultwarden writes to db.sqlite3, at the root of the data directory. Copying that file while the service is running is not a backup: SQLite keeps recent transactions in a -wal journal next to it, and a cp catches one file and not the other, or both at two different instants.
A hot
cpofdb.sqlite3produces a file that opensThat is what makes the trap expensive: the copied file is not empty, it opens, it contains tables. What is missing is the last writes, or the pages are inconsistent with each other. The
.backupcommand ofsqlite3takes a read lock, follows the journal, and produces a consistent file without stopping the service.
PostgreSQL or MySQL
With DATABASE_URL, the database lives elsewhere and is backed up like any other, with pg_dump or mysqldump. Only the dump line changes:
pg_dump -h db.internal -U backup_user -d vaultwarden --no-owner --no-acl \
| gzip > "/backups/vaultwarden_${ts}.sql.gz"
In both cases, the data directory is backed up the same way.
The backup script
#!/bin/bash
set -euo pipefail
ts=$(date +%Y%m%d_%H%M%S)
src=/var/lib/vaultwarden
dest=s3://backups-vaultwarden
sqlite3 "${src}/db.sqlite3" ".backup '/backups/db_${ts}.sqlite3'"
gzip -9 "/backups/db_${ts}.sqlite3"
tar -cf - -C "${src}" --exclude=./icon_cache --exclude='./db.sqlite3*' . \
| gzip > "/backups/data_${ts}.tar.gz"
aws s3 cp "/backups/db_${ts}.sqlite3.gz" "${dest}/"
aws s3 cp "/backups/data_${ts}.tar.gz" "${dest}/"
The database is excluded from the archive: .backup has already taken it cleanly, and adding it to the tar would put back into the backup the very hot copy we just avoided. icon_cache is excluded because Vaultwarden rebuilds it on its own.
Without
pipefail, a truncated archive comes out as a successIn
tar -cf - ... | gzip > file, the shell only looks at the exit code of the last link. Iftarstops along the way (a file vanishing under it, a full disk),gzipstill compresses what it received and returns 0. The script returns 0, cron is happy, and the archive holds half the directory.set -o pipefail, included in theset -euo pipefailabove, makes the whole line fail as soon astarfails.
Keep the timestamp in both file names, and keep them paired: what restores is the database and the directory taken in the same minute, not the latest file on each side. For retention, a lifecycle rule on the bucket deletes objects older than N days with no script to maintain.
Restoring once, by hand
A backup is only proven once restored. Do it once, in full, in a throwaway container. It is also the procedure you will follow the day it matters.
mkdir -p /tmp/vw-test/data
tar -xzf /backups/data_20260918_010000.tar.gz -C /tmp/vw-test/data
gunzip -c /backups/db_20260918_010000.sqlite3.gz > /tmp/vw-test/data/db.sqlite3
docker run -d --name vw-test -p 8080:80 \
-e DOMAIN=http://localhost:8080 \
-v /tmp/vw-test/data:/data \
vaultwarden/server:<your version>
The image must carry the production version
Vaultwarden applies its schema migrations at start-up. An image older than the one that wrote the database refuses to start on it, and a newer image migrates the restored file, which is the right behaviour for a real restore, but not what you want from a test. Use your production's exact version, not
latest.
Then open http://localhost:8080 in a browser, log in with an account's master password, and display the password of one entry. That is the only step that proves the vault decrypts. It needs a human who knows a master password: nobody else can do it, and that is exactly what you want from a password manager.
Note how long it took. That is your real restore duration.
What to check in a restored database
That the file opens does not mean the vault is there. Four questions, in this order:
select count(*) from users where length(password_hash) > 0;
select count(*) from ciphers;
select max(updated_at) from ciphers;
select count(*) from users
where length(password_hash) > 0
and (akey is null or akey = '' or private_key is null or public_key is null);
- Accounts are left. An account with no password hash is an invited user who never logged in, not a user.
- Entries are left. Compare against the order of magnitude in production, not an exact number: a vault that grows is normal.
- The entries are recent. The last change should be yesterday's, not last month's. That is what catches a backup job that stopped running.
-
The encryption material followed. This query must return zero. An active account that lost its
akeyor its key pair is an unreadable vault that looks intact, the case described at the top of this page.
To check the integrity of a SQLite file, sqlite3 db.sqlite3 'pragma integrity_check;' reads every page, which a sha256sum does not: a digest proves the file has not changed since the copy, not that it was valid when copied. Run it on a copy, because SQLite needs to write to replay the -wal journal when opening.
On the file side, two commands are enough:
ls -l /tmp/vw-test/data/rsa_key.pem
find /tmp/vw-test/data -type f | wc -l
The signing key is there, and the tree has not melted away. Remember that second number: it is a floor to compare against on later restores.
Then destroy everything: docker rm -f vw-test and rm -rf /tmp/vw-test. What you have just handled is a production vault.
What this chain does not prove
It proves that a recent backup opens, that it contains accounts and entries, and that each account's encryption material is intact. It does not prove that an entry decrypts: that needs a master password, which has no business being in a script. If the database and the data directory are checked separately, nothing verifies that every attachment cited by the database exists in the archive, which is why the two backups are taken in the same minute. And it says nothing about what has been added to the vault since the last backup: that gap is your RPO, and it is tuned with the backup frequency, not with the tests.
FAQ
Is backing up the Vaultwarden database enough?
No. The data directory holds the key that signs session tokens, the attachments of your entries, and the files behind Sends. More importantly, the two pieces depend on each other: attachments are referenced from the database, so a database and a file tree taken an hour apart give you entries pointing at files that are not there.
Can db.sqlite3 be copied while Vaultwarden is running?
Not with cp. SQLite keeps recent writes in a -wal journal next to the main file, and a plain copy catches one without the other. The .backup command of sqlite3 produces a consistent file without stopping the service.
Does a Vaultwarden backup contain my passwords in the clear?
No: every entry is encrypted in the browser before it reaches the server, and the master password never leaves the user's machine. A backup still has to be treated like the vault itself, because it contains everything needed to attack the master passwords offline.
How do you verify that a restored vault really decrypts?
By logging in with a master password and displaying an entry. An automated check cannot do that, and should not be able to: there is no place to put a master password that is not also a place to steal it from. What an automated check can verify is that each account's encryption material is present and complete, which is the failure nobody sees.
Which image version should the restore test use?
Your production's exact version, not latest. Vaultwarden applies its schema migrations at start-up: an image older than the one that wrote the database refuses to start on it, and a newer image migrates the restored file. That is the right behaviour for a real restore, not for a test, which has to read the database as it was backed up.
Originally published on restoreproof.io.
RestoreProof runs this kind of restore test on a schedule, inside your own infrastructure, and signs a report proving the backup restores. Your data never leaves your servers. Free plan, no credit card: https://app.restoreproof.io
Top comments (0)