I’ve actually done some digging into this as part of setting up Bitwarden Lite as part of considering migrating across from 1Password.
Where I landed on achieving this was:
- Using
rclone to mount a cloud location for the backup (easiest way to achieve this).
- Performing a scheduled backup, including:
- Stopping Bitwarden’s docker instance (and restarting when complete, or it fails)
- Going into the Docker folders for the Container, creating a backup of the DB file, with a date and time stamp, and encrypting it.
- Uploading of the new encrypted file to the cloud mount point.
- Rotating any existing backup files within defined parameters.
This is the code I ended up with, and hope it helps you with what you’re seeking to achieve.
Create encryption key
# Age keys (root‑only)
sudo mkdir -p /root/age && sudo chmod 700 /root/age
sudo age-keygen -o /root/age/keys.txt
sudo chmod 600 /root/age/keys.txt
# Derive the public key
sudo age-keygen -y /root/age/keys.txt
Backup script
#!/usr/bin/env bash
set -euo pipefail
umask 027
# --- Configuration ---
RCLONE_MOUNT="/mnt/foldername"
WORK_DIR="/opt/bitwarden/backups/work"
KEEP_LOCAL_DAYS=7
KEEP_REMOTE_DAYS=90
IN="/usr/bin/docker"
VOL="bitwarden_bw_etc" # confirm with `docker volume ls`
AGE_KEYFILE="/root/age/keys.txt"
# --- Derived names ---
STAMP="$(date +%Y-%m-%d_%H-%M-%S)"
BASENAME="bitwarden_${STAMP}"
TAR_GZ="${WORK_DIR}/${BASENAME}.tar.gz"
SEALED="${TAR_GZ}.age"
SUM="${SEALED}.sha256"
# --- Prep ---
mkdir -p "${WORK_DIR}"
cd /opt/bitwarden
# Resolve Age recipient from key file
if [ -r "$AGE_KEYFILE" ]; then
RECIPIENT=$(sed -n 's/^# public key: //p' "$AGE_KEYFILE" | head -n1)
fi
: "${RECIPIENT:?Age recipient not found; run: sudo age-keygen -o /root/age/keys.txt && sed -n 's/^# public key: //p' /root/age/keys.txt}"
# Always bring Bitwarden back up on exit
finish() { sudo "$IN" compose start bitwarden >/dev/null 2>&1 || true; }
trap finish EXIT
# 1) Stop briefly for a consistent SQLite snapshot
sudo "$IN" compose stop bitwarden || true
# 2) Snapshot the named volume -> gzipped tar (NO shell, NO redirects)
sudo "$IN" run --rm \
-v "$VOL:/data:ro" \
-v "$WORK_DIR:/out" \
alpine:3.20 \
tar -C /data -czf "/out/$(basename "$TAR_GZ")" .
# 3) Start Bitwarden again ASAP
sudo "$IN" compose start bitwarden || true
# 4) Encrypt with Age
age -r "$RECIPIENT" -o "$SEALED" "$TAR_GZ"
# 5) Checksum
sha256sum "$SEALED" > "$SUM"
# 6) Copy to OneDrive (if mounted)
if mountpoint -q "${RCLONE_MOUNT}"; then
cp -f "$SEALED" "$SUM" "${RCLONE_MOUNT}/"
else
echo "WARNING: ${RCLONE_MOUNT} not mounted; leaving encrypted backup locally at $SEALED" >&2
fi
# 7) Local retention
find "${WORK_DIR}" -type f -name 'bitwarden_*.tar.gz' -mtime "+$KEEP_LOCAL_DAYS" -delete || true
find "${WORK_DIR}" -type f -name 'bitwarden_*.tar.gz.age' -mtime "+$KEEP_LOCAL_DAYS" -delete || true
find "${WORK_DIR}" -type f -name 'bitwarden_*.tar.gz.age.sha256' -mtime "+$KEEP_LOCAL_DAYS" -delete || true
# 8) Remote retention
if mountpoint -q "${RCLONE_MOUNT}"; then
find "${RCLONE_MOUNT}" -type f -name 'bitwarden_*.tar.gz.age' -mtime "+$KEEP_REMOTE_DAYS" -delete || true
find "${RCLONE_MOUNT}" -type f -name 'bitwarden_*.tar.gz.age.sha256' -mtime "+$KEEP_REMOTE_DAYS" -delete || true
fi
echo "Backup complete: $SEALED"
Backup service using /etc/systemd/system/bitwarden-backup.service:
[Unit]
Description=Bitwarden encrypted backup (tar.gz + Age → OneDrive)
After=docker.service rclone-mount-bitwarden.service
Requires=docker.service rclone-mount-bitwarden.service
OnFailure=bitwarden-backup-alert@%n.service
[Service]
Type=oneshot
WorkingDirectory=/opt/bitwarden
TimeoutStartSec=1200
ExecStart=/usr/local/sbin/bitwarden-backup.sh
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bitwarden-backup
Backup timer using /etc/systemd/system/bitwarden-backup.timer:
[Unit]
Description=Run Bitwarden backup daily
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
[Install]
WantedBy=timers.target
Enabling the backup:
sudo systemctl daemon-reload
sudo systemctl enable --now bitwarden-backup.timer
systemctl list-timers | grep -i bitwarden-backup || true
Getting the latest backup from my cloud mount point, and decrypting for restoration (inc. stopping and starting Bitwarden lite):
# 1) Copy the desired .tar.gz.age from remote location to a working dir
cp /mnt/folder/bitwarden_YYYY-mm-dd_HH-MM-SS.tar.gz.age /opt/bitwarden/backups/work/
# 2) Decrypt & extract
cd /opt/bitwarden/backups/work
age -d -i /root/age/keys.txt -o restore.tar.gz bitwarden_*.tar.gz.age
mkdir -p restore && tar -C restore -xzf restore.tar.gz
# 3) Stop the app
cd /opt/bitwarden && sudo docker compose stop bitwarden
# 4) Replace contents of the named volume
VOL="bitwarden_bw_etc"
sudo docker run --rm -v "$VOL:/data" -v "/opt/bitwarden/backups/work/restore:/src" alpine:3.20 \
sh -lc 'cp -a /src/. /data/ && chown -R 1000:1000 /data'
# 5) Start the app
sudo docker compose start bitwarden
Decrypting the backup separately (if needed for manual testing):
LATEST=$(ls -1t /opt/bitwarden/backups/work/bitwarden_*.tar.gz.age | head -n1)
sudo age -d -i /root/age/keys.txt -o /opt/bitwarden/backups/work/_restore.tar.gz "$LATEST"
tar -tzf /opt/bitwarden/backups/work/_restore.tar.gz | head
rm -f /opt/bitwarden/backups/work/_restore.tar.gz