Practical guide

Back up Vaultwarden: the database, the data directory, and proof the vault can be restored

Why a Vaultwarden database restored without its encryption material hands back no readable secret, how to back up a SQLite install without corrupting it, restoring by hand in a throwaway container, and the four queries that prove a vault is still there.

September 2026· 13 min read·fr

Back up Vaultwarden and verify the vault can be restored

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. By the end you will know what a backup has to contain besides the database, why copying a SQLite file while it is open is not a backup, how to write a script that cannot report success on a truncated archive, how to restore into a throwaway container and actually open the vault, and which queries tell you a restored vault is still a vault.

Everything up to that point works with sqlite3, tar, docker and the AWS CLI, and nothing else. The last section shows how to run the same checks on a schedule instead of by hand, which is what RestoreProof does — but the procedure stands on its own.

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_key and public_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:

ContentWhat it is
rsa_key.pemthe 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.jsonthe 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 cp of db.sqlite3 produces a file that opens

That 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 .backup command of sqlite3 takes 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. The details of the dump, the formats, and what pg_dump leaves out are in Back up PostgreSQL to S3; only the line changes:

pg_dump -h db.interne -U sauvegarde -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://sauvegardes-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 success

In tar -cf - … | gzip > file, the shell only looks at the exit code of the last link. If tar stops along the way — a file vanishing under it, a full disk — gzip still 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 the set -euo pipefail above, makes the whole line fail as soon as tar fails.

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-essai/data
tar -xzf /backups/data_20260918_010000.tar.gz -C /tmp/vw-essai/data
gunzip -c /backups/db_20260918_010000.sqlite3.gz > /tmp/vw-essai/data/db.sqlite3

docker run -d --name vw-essai -p 8080:80 \
  -e DOMAIN=http://localhost:8080 \
  -v /tmp/vw-essai/data:/data \
  vaultwarden/server:<votre 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

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);
  1. Accounts are left. An account with no password hash is an invited user who never logged in, not a user.
  2. Entries are left. Compare against the order of magnitude in production, not an exact number: a vault that grows is normal.
  3. 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.
  4. The encryption material followed. This query must return zero. An active account that lost its akey or its key pair is an unreadable vault that looks intact — the case described at the top of this page.

On the file side, two commands are enough:

ls -l /tmp/vw-essai/data/rsa_key.pem
find /tmp/vw-essai/data -type f | wc -l

The signing key is there, and the tree has not melted away. Remember that second number: it is the floor you will give to the automated check.

Then destroy everything: docker rm -f vw-essai and rm -rf /tmp/vw-essai. What you have just handled is a production vault.

Automating this verification

What precedes costs an hour or two, every time. That is the reason these tests, done by hand, end up not being done at all.

RestoreProof replays these steps as a scheduled task, on your own infrastructure: a runner fetches the backup, restores it in a disposable container, asks the same questions, destroys everything, and signs the result. The data does not leave your premises.

First declare each backup as a source — the directory or the bucket, the file name pattern, and the most recently modified strategy. Credentials are not entered: the plan carries a reference, which the runner resolves in its own environment. See the sandbox password and how to adapt the fetch block.

As with the backup, the database and the data directory make two distinct plans: whichever one fails then tells you which of the two pieces is at fault. Every command you have just typed has its step:

By handIn the plan
aws s3 cp from the bucketfetch, which takes the most recent file
gunzip -c, tar -xzfunpack
docker run postgres:16-alpinestart_sandbox, for a PostgreSQL install
psql -v ON_ERROR_STOP=1restore_postgres, for a PostgreSQL install
sqlite3 db.sqlite3 on a copya sqlite probe, with no sandbox
the four queriesone probe per question
ls -l rsa_key.pem, find … | wc -la filesystem-canary probe
docker rm -fthe cleanup, always executed

The probes involved, and what each one proves:

  • sqlite with RESTOREPROOF_SQLITE_INTEGRITY: full reads every page of the file. That is what a sha256sum does not tell you: a digest proves the file has not changed since the copy, not that it was valid at the time of the copy. A backup taken with cp on a live database fails this check. The probe works on a copy of the file, because SQLite needs to write in order to replay the -wal journal when opening it.
  • sqlite or postgres for accounts and entries, with the gte operator and a floor.
  • sqlite or postgres for the encryption material, with the zero operator: the probe does not expect a number, it demands that no account be concerned. That is the probe worth the detour.
  • filesystem-canary for data/rsa_key.pem, with RESTOREPROOF_CANARY_MIN_FILES and RESTOREPROOF_CANARY_MIN_TOTAL_SIZE: the expected file is there, and the tree still weighs what it should.

One detail about the sqlite probe: RESTOREPROOF_SQLITE_TARGET expects a table name and quotes it as such, so a condition does not go there. Queries with a where are written in RESTOREPROOF_SQLITE_QUERY, which must return a single number.

The only addition compared with what you did by hand is max_age, and it is the check a restore cannot deduce from the content: it fails the run when the most recent backup found at the source is older than that. The full plans, ready to paste into the editor, are in the self-hosted applications recipe.

What this verification does not do: it decrypts nothing. No probe knows any master password, and that is deliberate — there is no place to put one that is not also a place to steal it from. It proves that the encryption material is present, complete, and attached to the right accounts. Actually opening a vault remains the step from the previous section, to be redone from time to time, by hand.

Thresholds are not copied from this page. A trial restores your backup, counts what it actually contains, and suggests each threshold 5 % below the measured value, with the gte operator.

That leaves choosing a frequency. Every night puts the run after your backup script: the backup is then one hour old when it is tested. The other possible trigger is an HTTP call at the end of that script — the test then covers exactly the files that were just produced.

Each run leaves a timestamped, signed report naming the backup that was tested and what each probe measured.

The detail of a run: the Ed25519 signature, the result of each probe with its measurements, and the log

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 here. Since the two plans are independent, nothing verifies either that every attachment cited by the database exists in the data directory 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.

Verify every restore, continuously

RestoreProof replays these steps on your own infrastructure, as often as you choose: it fetches the backup, restores it in a disposable container, asks the same questions, destroys everything, and signs the result. Your data never leaves your network, and nothing is decrypted.

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 the automated check verifies 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.

vaultwarden backuprestore vaultwardenself-hosted bitwarden backupsqlite3 backup commandverify backupvaultwarden rsa_key.pem
Available now

Ready to prove your restores?

RestoreProof automates restore testing and produces cryptographically signed evidence — without your data ever leaving your infrastructure.