SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Vaultwarden backup and restore on a VPS

Copy a live Vaultwarden vault with sqlite3 .backup, keep the attachments, config.json and rsa_key files, then prove the restore works before you need it.

What a Vaultwarden backup must contain

A Vaultwarden backup is a copy of the whole data folder, and the database inside it has to be copied the right way. Run sqlite3 db.sqlite3 ".backup out.sqlite3" instead of cp, because a plain copy of a database that is being written can give you a file that will not open. Then keep the files sitting next to it, which is the half people forget.

On a Docker install the data folder is whatever you mounted at /data. That is either a path on the host or a named volume, and the difference between bind mounts and named volumes decides where on disk your vault really lives. Here is what it holds.

  • db.sqlite3: every account, every vault item, every folder and every organisation. Losing this file loses the vault.
  • db.sqlite3-wal and db.sqlite3-shm: the write-ahead log (WAL) and its shared memory index. Recent writes live here until SQLite folds them into the main file.
  • attachments/: the files users attached to vault items, encrypted, in one directory per item.
  • sends/: the files behind Bitwarden Send links.
  • config.json: every setting you saved from the admin page.
  • rsa_key.pem, plus rsa_key.der and rsa_key.pub.der on older installs: the key that signs login tokens.
  • icon_cache/: downloaded website icons. This is the one directory you can skip, because Vaultwarden fetches them again on demand.

Is my Vaultwarden database safe? What the file really holds

Two commands answer that, and you can run both right now.

sudo apt update && sudo apt install -y sqlite3
sudo sqlite3 /opt/vaultwarden/data/db.sqlite3 "select email from users;"
sudo sqlite3 /opt/vaultwarden/data/db.sqlite3 "select name from ciphers limit 1;"

The first prints your users' email addresses in cleartext. The second prints one item name, and it looks like this:

2.k9Qw1nQ0y7Yy2Xw==|E1r0J3l5s7d9f1g3h5j7k9==|Lm4nOp6qRs8tUv0wXy2zAb4cDe6fGh8i=

Item names, usernames, passwords and notes are encrypted by the client before they are sent, so the server stores ciphertext it cannot read. The 2. prefix is Bitwarden's encryption type, followed by an initialisation vector (IV), the ciphertext, and a MAC (message authentication code), each in base64 and separated by |. The key that decrypts it is derived from the account's master password, which never reaches the server in a usable form. This part is identical whether you run Vaultwarden or the official server, as the comparison of Vaultwarden and self-hosted Bitwarden goes into.

The rest of the database is not encrypted. Email addresses, account names, password hints and two-factor recovery codes are stored as plain text, next to metadata such as creation times and which organisation owns an item. So the backup file is itself a secret. Anyone who holds it learns who your users are, and can attack the encrypted blobs offline at whatever speed their hardware allows. That single fact drives the storage rules further down: the copy gets encrypted before it leaves the server.

Why copying db.sqlite3 while Vaultwarden runs is not a backup

Vaultwarden runs SQLite in WAL mode by default (ENABLE_DB_WAL=true). A write lands in db.sqlite3-wal first, and only a checkpoint folds it into db.sqlite3. Copy db.sqlite3 on its own and you get the database as of the last checkpoint, so a password saved ten minutes ago can be missing from your archive with nothing to warn you.

Copying all three files with cp is not a fix either. The copies are taken at slightly different moments, so the WAL you saved can describe page versions that the main file you saved no longer matches. SQLite then recovers one from the other and the result is wrong. You find out much later:

Error: database disk image is malformed

.backup avoids this because it uses SQLite's Online Backup API, which SQLite documents as the way to copy a database that may be in active use. It reads the pages under a read lock, and it starts over if a writer changes the file underneath it, so what lands on disk is one consistent moment.

Take the database copy with sqlite3 .backup

sudo apt update && sudo apt install -y sqlite3
sudo install -d -m 700 /var/backups/vaultwarden
OUT=/var/backups/vaultwarden/db-$(date '+%Y%m%d-%H%M').sqlite3
sudo sqlite3 /opt/vaultwarden/data/db.sqlite3 ".backup '$OUT'"
sudo sqlite3 "$OUT" "PRAGMA integrity_check;"

The last command prints ok on a line of its own. Anything else means the copy is not usable, so do not keep it and do not delete the previous one. The whole sequence runs against a live server, so nobody is logged out and no container restarts.

The sqlite3 tool is not inside the Vaultwarden container. The image is built on debian:trixie-slim with ca-certificates, curl, libmariadb3, libpq5 and openssl, so docker exec vaultwarden sqlite3 ... fails with:

exec: "sqlite3": executable file not found in $PATH

Run it on the host against the mounted path instead, which is what the commands above do. If the data sits in a named volume, docker volume inspect <name> prints the host path under /var/lib/docker/volumes/.

Vaultwarden has also shipped its own backup command since version 1.32.1. On your server:

docker exec -it vaultwarden /vaultwarden backup

It runs VACUUM INTO and writes db_YYYYMMDD_HHMMSS.sqlite3 into the data folder. Two things follow. The copy lands next to the original on the same disk, so it is a staging step and not yet a backup. And it is SQLite only: on MariaDB or PostgreSQL it stops with The database type is not SQLite. Backups only works for SQLite databases.

The files people forget

attachments/ holds ciphertext under opaque names. The database row for each attachment carries its encrypted file name and the key material a client needs to decrypt the file. Attachments without the database are unreadable noise, and a database without the attachments gives users items whose downloads fail. Take both in the same run.

config.json holds everything you saved from the admin page, and its values take precedence over the matching environment variables. That cuts both ways: restoring an old config.json quietly overrides the settings in your compose file, and the file itself is sensitive because it can hold your SMTP password and your admin token. Store that token as an Argon2id PHC (password hashing competition) string rather than plain text. docker run --rm -it vaultwarden/server /vaultwarden hash prints one for you.

rsa_key.pem signs the JSON web tokens (JWT) that keep clients logged in. If the file is missing at startup, Vaultwarden generates a new key, so every token signed by the old one stops validating and all clients are logged out. Vault contents survive that, because they are encrypted with keys derived from the master password. Restoring the key file avoids the mass logout.

sends/ holds the files behind Send links. Missing them breaks those downloads and nothing else.

Put the whole thing in one script

#!/bin/bash
set -euo pipefail

DATA=/opt/vaultwarden/data
DEST=/var/backups/vaultwarden
STAMP=$(date '+%Y%m%d-%H%M%S')
STAGE=$(mktemp -d /tmp/vw-stage.XXXXXX)

install -d -m 700 "$DEST"
sqlite3 "$DATA/db.sqlite3" ".backup '$STAGE/db.sqlite3'"
test "$(sqlite3 "$STAGE/db.sqlite3" 'PRAGMA integrity_check;')" = "ok"
cp -a "$DATA"/rsa_key* "$STAGE/"
for extra in config.json attachments sends; do
  if [ -e "$DATA/$extra" ]; then cp -a "$DATA/$extra" "$STAGE/"; fi
done
tar -C "$STAGE" -czf "$DEST/vw-$STAMP.tar.gz" .
chmod 600 "$DEST/vw-$STAMP.tar.gz"
rm -rf "$STAGE"
tar -tzf "$DEST/vw-$STAMP.tar.gz"

Save it as /usr/local/sbin/vw-backup.sh, chmod 700 it, and run it as root. The test line is doing real work: sqlite3 exits 0 even when PRAGMA integrity_check reports corruption, so comparing the output to ok is what turns a bad copy into a failed script. set -euo pipefail then stops everything, instead of letting tar build a neat archive around a broken database.

The final tar -tzf lists what you actually captured. Read it the first time. You are looking for ./db.sqlite3, ./rsa_key.pem, ./config.json and ./attachments/, and for the absence of ./db.sqlite3-wal. Run it nightly with a systemd service and timer rather than cron if you want journalctl output and a unit that reports failure.

Verify the backup by restoring it into a scratch directory

An untested backup is a guess. Restoring into a scratch directory takes a minute and touches nothing live.

sudo install -d -m 700 /tmp/vw-check
sudo tar -C /tmp/vw-check -xzf /var/backups/vaultwarden/vw-20260805-030000.tar.gz
ls -l /tmp/vw-check
sudo sqlite3 /tmp/vw-check/db.sqlite3 "PRAGMA integrity_check;"
sudo sqlite3 /tmp/vw-check/db.sqlite3 "select count(*) from users;"
sudo sqlite3 /tmp/vw-check/db.sqlite3 "select count(*) from ciphers;"
sudo du -sh /tmp/vw-check/attachments

Four results matter. integrity_check prints ok. The user count matches the number of accounts you know about. The cipher count is close to the live figure from sudo sqlite3 /opt/vaultwarden/data/db.sqlite3 "select count(*) from ciphers;", and it is never zero on a vault in use. The attachments directory is roughly the size you expect, which you can skip if nobody uploads attachments. Then run sudo rm -rf /tmp/vw-check, because that directory now holds a second copy of everything.

One rule when you restore any hand-copied data folder: delete db.sqlite3-wal and db.sqlite3-shm before starting the server. SQLite will otherwise try to recover the restored database using a log that belongs to a different copy of it, and that corrupts a database that arrived intact. Archives produced by the script above never contain those files, because .backup writes one complete database.

Restore onto the server

These run on your own server, with the container stopped. Vaultwarden must not be writing while the data folder changes underneath it.

cd /opt/vaultwarden
docker compose stop vaultwarden
sudo mv data data.old.$(date '+%Y%m%d-%H%M%S')
sudo install -d -m 700 data
sudo tar -C data -xzf /var/backups/vaultwarden/vw-20260805-030000.tar.gz
sudo chown -R root:root data
docker compose start vaultwarden
docker compose logs --tail 20 vaultwarden

The chown has to name the user the container runs as. The stock image runs as root, so root:root is right unless you set user: in your compose file, in which case use that uid and gid. A data folder the server cannot write gives you a login page that fails every request, and the logs say so.

A healthy start ends with the Rocket line:

[INFO] Rocket has launched from http://0.0.0.0:80

Then log in from a browser, open an item, and download one attachment. A login that works while attachment downloads fail means the archive carried the database but not attachments/. Keep data.old.* until all of that checks out, then delete it. Rolling back is the same three steps with the directories swapped the other way.

If your paths do not match the ones here, the Vaultwarden install guide for a VPS shows the compose file these commands assume.

Where not to put the backup

  • Not on the same disk as the data folder. One failed volume takes both copies, and so does one rm -rf on the wrong path.
  • Not on the same server, even on a second volume. An attacker who reaches root reaches your backups in the same session.
  • Not in object storage without encryption, because the archive holds email addresses, password hints, recovery codes and vault ciphertext that can be attacked offline.
  • Not only in your provider's snapshots. They restore fast, which is worth having, but they live in the same account as the server, so an account problem takes them with it.

An offsite copy is where restic fits, because a restic repository is encrypted on the machine before anything is uploaded. On your server:

sudo apt install -y restic
export RESTIC_REPOSITORY=s3:https://s3.example.com/vaultwarden-backups
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic init
restic backup /var/backups/vaultwarden --tag vaultwarden
restic snapshots --tag vaultwarden
restic forget --tag vaultwarden --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

Point restic at the archive directory and not at the live data folder, so what it uploads is the consistent copy you already checked. Keep the repository password somewhere other than the server it protects: lose that password and the snapshots are unreadable, by design. Where the storage supports it, give the server credentials that can write but not delete, so a compromise of the box cannot wipe its own history. Setting up restic backups on a VPS covers the repository and the schedule in full, and restic compared with BorgBackup covers the choice if you have not made it yet.

Test the restore on a schedule

Pick one day a month. Pull the newest snapshot into a scratch directory with restic restore latest --tag vaultwarden --target /tmp/vw-check, run the same PRAGMA integrity_check, run the same row counts, then write the date and the counts down. A backup nobody has restored in six months is a backup of unknown state, and you learn its state during an outage, which is the worst moment to learn it.

Once a year, do the full version. Start a second Vaultwarden container on a spare port with the restored data folder, and log in with a real account. That proves the master password path from end to end, which no row count can do. restic check --read-data-subset=10% on the same schedule verifies that the stored data is readable rather than merely listed.

FAQ

Can I copy db.sqlite3 with cp while Vaultwarden is running?

No. Vaultwarden runs SQLite in WAL mode, so recent writes sit in db.sqlite3-wal and are not yet in db.sqlite3. A cp of the main file alone loses them silently, and copying the two files separately can leave a mismatched pair that surfaces later as Error: database disk image is malformed. Use sqlite3 /path/db.sqlite3 ".backup '/path/out.sqlite3'" instead. It uses SQLite's Online Backup API and produces one consistent file while the server keeps serving.

Do I have to stop the Vaultwarden container to take a backup?

No, and that is the point of .backup. The database copy is safe on a running server. Attachments and Send files are written when a user uploads one, so a file added between the database copy and the tar could miss that night's archive, which costs you one attachment at worst. If a few seconds of downtime does not bother you, docker compose stop before the script and docker compose start after it removes even that.

What happens if I restore without the rsa_key files?

Vaultwarden generates a new key at startup. That key signs the JSON web tokens (JWT) that keep sessions alive, so every existing token stops validating and all clients are logged out and must sign in again. Vault contents are unaffected, because they are encrypted with keys derived from each user's master password rather than with the RSA key. Restore rsa_key.pem along with the rest of the data folder and nobody notices the restore.

Is the backup archive safe to upload to object storage as it is?

No. Item names, passwords and notes are ciphertext, but email addresses, account names, password hints and two-factor recovery codes are plain text in the database, and an offline attacker can grind the ciphertext at their own pace. Encrypt the archive before it leaves the machine. A restic repository does that for you, and gpg --symmetric --cipher-algo AES256 vw-20260805-030000.tar.gz produces a single encrypted file you can hand to any storage.

How do I back up Vaultwarden on PostgreSQL or MariaDB?

The SQLite steps do not apply, and the built-in command refuses with The database type is not SQLite. Backups only works for SQLite databases. Dump the database with its native tool, pg_dump or mysqldump, and keep every other rule the same. The dump belongs in one archive with attachments/, sends/, config.json and the rsa_key files, taken in the same run, encrypted, and stored somewhere other than the server that made it.