Skip to content
Go back

When Vaultwarden Dies: A Recovery Drill

By KingPin 16 min read
When Vaultwarden Dies: A Recovery Drill
Contents

It’s 2 AM and the Vault Is Gone

The disk under your homelab box made a noise, then stopped making any noise. Your Vaultwarden container is gone. So is the only copy of every password you own, including the one for the registrar that points your domain at the machine that just died.

The way out is short. Your Vaultwarden backup is one SQLite file plus a few files people forget: rsa_key.pem, attachments/, sends/, and config.json. The Bitwarden clients keep a cached, read-only copy of your vault, and that copy buys you time while you rebuild. And a backup you have never restored is a rumor, so you run a restore drill before you need one.

Below you get what is in /data, what each failure costs you, how to back it up consistently, how to keep a second copy that does not depend on your server at all, and how to prove the whole thing works. All version details below were checked on 2026-10-27. As of October 2026 the current Vaultwarden release is 1.37.4, published as vaultwarden/server.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/vaultwarden-disaster-recovery

What Is Actually in /data

The Vaultwarden wiki page “Backing up your vault” lists what lives in the data directory and how much each piece matters:

PathWiki verdictWhat it holds
db.sqlite3RequiredThe main database: nearly all important state
attachments/RequiredFile attachments, the only important data outside the database (absent if you have none)
config.jsonRecommendedAdmin page settings. Contains some plaintext sensitive data
rsa_key*RecommendedSigns login tokens
sends/OptionalSend file attachments, meant to be ephemeral
icon_cache/OptionalCached site icons, usually not worth backing up
db.sqlite3-shmNot neededSQLite shared-memory file
db.sqlite3-walSee restore rulesSQLite write-ahead log

The surprise for most people is the WAL file. SQLite in WAL mode keeps recent writes in db.sqlite3-wal until it merges them into the main file. If you cp db.sqlite3 while Vaultwarden is running, you can grab a file that is missing the last few minutes of writes, or a file that is inconsistent with its log. A plain file copy of a live database is a coin flip with your passwords on it.

The directory looks like this on a freshly started instance:

Terminal window
ls /srv/vaultwarden/vw-data
db.sqlite3 db.sqlite3-shm db.sqlite3-wal rsa_key.pem tmp

The attachments/ and sends/ directories appear when someone uploads a file. Your backup script has to tolerate both being absent.

The Failure Scenarios and What Each One Costs

Not every failure is the same size. The menu:

The disk died and you have no backup

You lose everything on the server. Your clients still hold cached copies (next section), so you can still read passwords from an unlocked client. That is the scenario this post exists to prevent.

The disk died and you have a backup

You provision a new host, restore the archive, and start the container. Users keep their vaults. Because you restored rsa_key.pem too, existing sessions keep working.

The database is corrupted

Same fix as above, from the newest backup that passes PRAGMA integrity_check. You lose whatever changed since that backup. This is why daily is the floor, and why the backup script checks integrity at backup time instead of discovering rot during an emergency.

You lost rsa_key*

The wiki says users would be logged out and required to log in again, and open invitation tokens sent by email would be invalidated. Vault data lives in the database, so it is unaffected. This is an annoyance, not a disaster. The wiki also notes the private key is somewhat sensitive, so encrypt it when you store it off-site.

You lost the admin token

You lose access to /admin, not your vault. Set a new ADMIN_TOKEN environment variable and recreate the container. One gotcha from the wiki’s admin page documentation: values in config.json take precedence over the matching environment variable, and once you save settings through the admin page, environment variables are no longer used for those values. So if a new token does nothing, check config.json for a stale admin_token key. The wiki recommends generating a long random token, for example with openssl rand -base64 32, and hashing it with argon2id via vaultwarden hash.

The whole host is gone

This is the same as a dead disk, with one extra wrinkle: your backups cannot live on that host. A backup directory on the same disk as the data is a very organized way to lose both at once. Copy archives off the box (a second machine, object storage, a USB drive in a drawer).

Your Clients Are a Safety Net

Bitwarden’s help page “Using Bitwarden Offline” says: “Any unlocked Bitwarden app can be used offline in read-only mode.” It lists airplane mode and not being connected to your self-hosted server as the situations it covers. Most functions work, but the page is explicit that you will not be able to make edits or add vault items, attachments, or sends, or import new items.

In practice that means the desktop app, mobile apps, and browser extension that are already logged in and unlocked can still show you passwords while Vaultwarden is down. The web vault is different by construction: Vaultwarden itself serves it, so a dead server means no web vault.

Three habits protect this safety net.

  1. Stay logged in, just locked. Community forum posts report that fully logging out of a client purges its local vault data, and that logging back in needs the server. Do not log out of your clients on a whim.
  2. Do not mistake errors for outages. In the Bitwarden community thread on clients logging out when the server is down, a Bitwarden staff reply says the client logs out on an error response on purpose. A server that is reachable but returns errors (a misconfigured reverse proxy, say) is more dangerous to the cache than one that is unreachable.
  3. Treat the cache as a buffer, not a backup. Other community posts report that a client which deauthorizes its session purges the local cache, so take the device offline before you launch a client you want to read from.

When the server dies, do this first:

  1. Do not log out of any client.
  2. Do not reinstall or “reset” the desktop app.
  3. On a client with a working cache, unlock it and, while you are still offline, copy the critical logins (registrar, DNS, email, the host’s own admin credentials) somewhere safe.
  4. Then start the rebuild.

The official docs do not describe how long an offline cache lasts, so assume it is a buffer of hours to days, not a plan.

The Backup Script

The Vaultwarden Docker image includes neither sqlite3 nor cron, per the wiki, so the backups run from the Docker host. The wiki recommends SQLite’s .backup command, which uses the Online Backup API and is safe while the server runs. It also documents VACUUM INTO (compacts free space, slower) and a built-in /vaultwarden backup command available since version 1.32.1. This kit uses .backup on the host.

Start with the compose file. The data directory is a bind mount so the host can read it:

docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:1.37.4
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.example.com"
SIGNUPS_ALLOWED: "false"
volumes:
- ./vw-data:/data
ports:
- "127.0.0.1:8080:80"

The backup script does four things: it takes a consistent copy of the database, verifies that copy, adds the files people forget, and prunes old archives.

backup.sh
#!/usr/bin/env bash
# Consistent Vaultwarden backup. Runs on the Docker host and needs sqlite3 installed.
# Usage: DATA_DIR=/srv/vaultwarden/vw-data BACKUP_DIR=/backups ./backup.sh
# Optional: AGE_RECIPIENT=age1... encrypts the archive with age. KEEP=14 sets retention.
set -euo pipefail
DATA_DIR="${DATA_DIR:-/srv/vaultwarden/vw-data}"
BACKUP_DIR="${BACKUP_DIR:-/backups}"
KEEP="${KEEP:-14}"
STAMP="$(date '+%Y%m%d-%H%M%S')"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$BACKUP_DIR"
umask 077
# 1. Online-safe copy of the database. Safe while Vaultwarden is running.
sqlite3 "$DATA_DIR/db.sqlite3" ".backup '$WORK/db.sqlite3'"
[ "$(sqlite3 "$WORK/db.sqlite3" 'PRAGMA integrity_check;')" = "ok" ] \
|| { echo "integrity_check failed on fresh backup" >&2; exit 1; }
# 2. The files people forget. Missing ones are skipped (attachments/ and sends/ may not exist).
extras=()
for f in rsa_key.pem rsa_key.pub.pem attachments sends config.json; do
[ -e "$DATA_DIR/$f" ] && extras+=("$f")
done
ARCHIVE="$BACKUP_DIR/vaultwarden-$STAMP.tar.gz"
tar -czf "$ARCHIVE" -C "$WORK" db.sqlite3 -C "$DATA_DIR" "${extras[@]}"
# 3. Optional encryption. The archive holds rsa_key.pem and plaintext config.json.
if [ -n "${AGE_RECIPIENT:-}" ]; then
age -r "$AGE_RECIPIENT" -o "$ARCHIVE.age" "$ARCHIVE"
rm -f "$ARCHIVE"
ARCHIVE="$ARCHIVE.age"
fi
# 4. Retention: keep the newest $KEEP archives.
# shellcheck disable=SC2012
ls -1t "$BACKUP_DIR"/vaultwarden-*.tar.gz* 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -f --
echo "wrote $ARCHIVE"

A few choices worth explaining. The script writes the database copy to a temp directory and checks it before it goes into the archive, so a bad backup fails loudly at 3 AM instead of quietly at 2 AM six months later. The archive deliberately has no -wal or -shm file, because the wiki says a .backup copy should be restored without them. And the encryption step exists because the wiki flags both config.json (plaintext sensitive data) and rsa_key.pem as things to encrypt when others can reach the backup.

Run it as root, since the container writes /data as root. Schedule it with a crontab line:

17 3 * * * DATA_DIR=/srv/vaultwarden/vw-data BACKUP_DIR=/backups /srv/vaultwarden/backup.sh

Then sync /backups somewhere that is not this machine. In our test run on 2026-10-27, against a live vaultwarden/server:1.37.4 container with sqlite3 3.54.0, the script produced an archive containing db.sqlite3 and rsa_key.pem, as expected for an instance with no attachments yet.

A Second Copy That Does Not Need Your Server

Everything above depends on you having the archive and a working host to restore it onto. An encrypted export from the Bitwarden CLI gives you a vault copy you can read without Vaultwarden at all. It is the parachute that works even if you packed the other one wrong.

Bitwarden’s export page lists the formats: plaintext .json, plaintext .csv, .json (Encrypted), and .zip (with attachments). The encrypted JSON comes in two flavors:

For disaster recovery you want password protected. The account-restricted file cannot go into a different account, which includes an account with the same email on a different server, and rotating your account encryption key makes it impossible to decrypt. If your server died, the account may be exactly what you are rebuilding.

Two limits to know. Per the same page, no export format includes trash items or Sends. And attachments are exported only in the zip format, which the page says is currently available for individual vault data. The JSON and CSV formats leave attachments out, which is another reason backup.sh copies the attachments/ directory.

Point the CLI at your server with bw config server, authenticate with an API key, and unlock. According to the CLI docs, bw login --apikey prompts for client_id and client_secret, or reads the BW_CLIENTID and BW_CLIENTSECRET environment variables for automation. The API key does not replace your master password, so you still need bw unlock, which accepts --passwordenv or --passwordfile. The docs say the password file’s first line is the master password and warn you to restrict its permissions to the user running the command.

export-vault.sh
#!/usr/bin/env bash
# Password-protected encrypted_json export of your personal vault with the Bitwarden CLI.
# Cron-friendly. Needs: bw, and these env vars:
# BW_CLIENTID, BW_CLIENTSECRET personal API key (Account settings > Security > Keys)
# BW_PASSWORD master password (or set PASSFILE to a 0600 file, first line = password)
# EXPORT_PASSWORD password that protects the export file
# Optional: BW_SERVER (default https://vault.example.com), OUT_DIR (default /backups/exports)
set -euo pipefail
BW_SERVER="${BW_SERVER:-https://vault.example.com}"
OUT_DIR="${OUT_DIR:-/backups/exports}"
OUT="$OUT_DIR/vault-$(date '+%Y%m%d').json"
: "${EXPORT_PASSWORD:?set EXPORT_PASSWORD}"
mkdir -p "$OUT_DIR"
umask 077
bw logout >/dev/null 2>&1 || true
bw config server "$BW_SERVER" >/dev/null
bw login --apikey >/dev/null
if [ -n "${PASSFILE:-}" ]; then
BW_SESSION="$(bw unlock --passwordfile "$PASSFILE" --raw)"
else
: "${BW_PASSWORD:?set BW_PASSWORD or PASSFILE}"
BW_SESSION="$(bw unlock --passwordenv BW_PASSWORD --raw)"
fi
export BW_SESSION
bw sync >/dev/null
bw export --format encrypted_json --password "$EXPORT_PASSWORD" --output "$OUT" >/dev/null
bw lock >/dev/null
bw logout >/dev/null
echo "wrote $OUT"

We syntax-checked this script but did not run it end to end, because the test box had no bw install and no test account. Run it once by hand against your own account, then import the result into a throwaway account to prove the password works. A plaintext .json or .csv export is readable by anyone who finds it, so never leave one lying around. The password-protected format exists so the file on disk is not a liability.

The master password sitting in an environment variable or a file on a cron host is itself a secret you now have to protect. For a personal setup, a 0600 file readable only by the backup user is a reasonable trade. For anything shared, run the export by hand once a month instead.

The Restore

When the day comes, the wiki’s restore rules are short. Stop Vaultwarden. Replace each file or directory in data with its backed-up version. If the backup came from .backup or VACUUM INTO, delete any existing db.sqlite3-wal first, because a stale WAL can corrupt the restored database. The wiki also tells you to keep a copy of your original data in case the backup turns out to be faulty.

The restore script follows that to the letter, and it moves the current data aside instead of deleting it:

restore.sh
#!/usr/bin/env bash
# Restore a backup.sh archive into the live data dir. Current data is moved aside, never deleted.
# Usage: ./restore.sh /backups/vaultwarden-YYYYmmdd-HHMMSS.tar.gz
# Env: DATA_DIR (default /srv/vaultwarden/vw-data), COMPOSE_DIR (default /srv/vaultwarden)
# Encrypted archives (.age): decrypt first with: age -d -i key.txt -o out.tar.gz in.tar.gz.age
set -euo pipefail
ARCHIVE="${1:?usage: $0 archive.tar.gz}"
DATA_DIR="${DATA_DIR:-/srv/vaultwarden/vw-data}"
COMPOSE_DIR="${COMPOSE_DIR:-/srv/vaultwarden}"
ASIDE="$DATA_DIR.old-$(date '+%Y%m%d-%H%M%S')"
( cd "$COMPOSE_DIR" && docker compose stop vaultwarden )
[ -e "$DATA_DIR" ] && mv "$DATA_DIR" "$ASIDE" && echo "old data moved to $ASIDE"
mkdir -p "$DATA_DIR"
tar -xzf "$ARCHIVE" -C "$DATA_DIR"
# The backup came from .backup, so a stale WAL would corrupt it. None is in the archive, but be sure.
rm -f "$DATA_DIR/db.sqlite3-wal" "$DATA_DIR/db.sqlite3-shm"
( cd "$COMPOSE_DIR" && docker compose up -d vaultwarden )
echo "integrity_check: $(sqlite3 "$DATA_DIR/db.sqlite3" 'PRAGMA integrity_check;')"

The last line should print ok. In our test, it did, after a stop, move-aside, extract, and start cycle. If it prints anything else, the old data is sitting next to the new directory and you have lost nothing.

The Restore Drill

The drill turns the rumor into a fact. It restores an archive into a temp directory, checks integrity, counts the users, and boots a throwaway Vaultwarden on a different port. The live instance is never touched.

drill.sh
#!/usr/bin/env bash
# Restore drill: boot a throwaway Vaultwarden from a backup archive on another port.
# Never touches the live instance. Usage: ./drill.sh /backups/vaultwarden-....tar.gz [port]
set -euo pipefail
ARCHIVE="${1:?usage: $0 archive.tar.gz [port]}"
PORT="${2:-8099}"
IMAGE="${IMAGE:-vaultwarden/server:1.37.4}"
DRILL="$(mktemp -d)"
NAME="vw-drill-$$"
cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -rf "$DRILL"; }
trap cleanup EXIT
tar -xzf "$ARCHIVE" -C "$DRILL"
echo "integrity_check: $(sqlite3 "$DRILL/db.sqlite3" 'PRAGMA integrity_check;')"
echo "users in backup: $(sqlite3 "$DRILL/db.sqlite3" 'SELECT count(*) FROM users;')"
docker run -d --name "$NAME" -p "127.0.0.1:$PORT:80" -v "$DRILL:/data" "$IMAGE" >/dev/null
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$PORT/alive" >/dev/null 2>&1; then
echo "drill instance is alive on http://127.0.0.1:$PORT"
echo "Log in with a real account to confirm, then press Enter to tear down."
[ -t 0 ] && read -r _
exit 0
fi
sleep 1
done
echo "drill instance did not come up" >&2
docker logs "$NAME" >&2
exit 1

Run it like this:

Terminal window
sudo ./drill.sh /backups/vaultwarden-20261027-030017.tar.gz 8099

Open http://127.0.0.1:8099 through an SSH tunnel and log in with a real account. If your vault shows up with your items in it, the backup is real. If the user count is zero on an instance that has users, you found the problem for free.

Do this once now, then put a calendar reminder on it. Quarterly is enough. The drill takes five minutes and the alternative is learning your backup is empty at the worst possible time.

The Checklist

  1. Back up with sqlite3 .backup, never cp on a live database.
  2. Include rsa_key*, attachments/, sends/, and config.json in the archive.
  3. Encrypt anything that leaves the host: rsa_key.pem and config.json are sensitive.
  4. Copy archives to a different machine. Same-disk backups are decoration.
  5. Run the integrity check at backup time, not restore time.
  6. Keep your clients logged in and locked. They are your buffer.
  7. Make a password-protected encrypted_json export on a schedule or by hand, and test importing it once.
  8. Run drill.sh today, and again every quarter.
  9. Pin the image tag (1.37.4 as of October 2026) so a restore does not double as a surprise upgrade.

Common Questions

Can I open my Bitwarden vault offline if Vaultwarden is down?

Yes, on a client that is already logged in and unlocked. Bitwarden’s documentation says any unlocked Bitwarden app can be used offline in read-only mode. You cannot edit or add items. The web vault does not work, because Vaultwarden itself serves it.

Do I need to back up rsa_key.pem for Vaultwarden?

Yes, though losing it is not fatal. The Vaultwarden wiki rates the rsa_key* files as recommended, not required. If you lose them, users are logged out and open email invitation tokens stop working. Vault data lives in the database and stays intact. Back the key up anyway, and encrypt the archive.

Is copying db.sqlite3 enough to back up Vaultwarden?

No. Vaultwarden keeps recent writes in db.sqlite3-wal, so a raw copy of a running database can be incomplete or inconsistent. Use sqlite3 db.sqlite3 ".backup ...", or the built-in /vaultwarden backup command, and add the attachments directory and the key files.

Does a Bitwarden export include my attachments?

Only the zip export format includes attachments, and only for individual vault data. Plain .json, .csv, and encrypted_json exports leave attachments out. No export format includes Sends or trash items. Back up the attachments/ directory on the server for those files.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
A Terminal Built for AI Coding Agents
Next Post
Context Window Math: Paying to Remember

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts