Backups
Everything Lector knows lives in one directory: the bind mount at ./data — a SQLite database (your vocabulary, progress, settings, accounts) plus your imported books. Back that directory up and you can rebuild the whole instance; lose it and it's gone. Dictionaries are re-downloadable and don't need backing up, but they live in the same directory and are small enough that it isn't worth excluding them.
Level 0: the one-liner
tar -czf lector-$(date +%Y%m%d).tar.gz -C ./data . That's a restorable backup. Everything below is about doing it on a schedule, keeping history, and getting it off the machine.
SQLite consistency: copy the whole directory — alongside lector.db there may be -wal and -shm files holding recent writes, and a tar of the directory picks them up automatically. A backup taken while Lector is running is crash-consistent (SQLite recovers it like a power loss). For belt and braces, wrap the tar in docker compose stop lector / docker compose start lector — at 3am nobody notices the ten seconds.
The system we actually run
Our homeserver backs up Lector (and everything else) with a three-part setup that's about 40 lines of shell in total: per-app job scripts, staged locally with retention, then one sync job that pushes the staging directory offsite. A runner executes the jobs nightly and sends a push notification if anything fails.
1. A job script per app
Each app gets a small script that stages a timestamped archive and prunes old ones. Lector's, verbatim:
#!/bin/bash
set -e
DATA_DIR="$HOME/home-server/apps/lector/data"
LOCAL_BACKUP="$HOME/backups/staged/lector"
mkdir -p "$LOCAL_BACKUP"
BACKUP_NAME="lector-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$LOCAL_BACKUP/$BACKUP_NAME" -C "$DATA_DIR" .
# Keep the last 30 days
find "$LOCAL_BACKUP" -name "lector-*.tar.gz" -mtime +30 -delete
echo "Lector backup complete: $BACKUP_NAME" Thirty daily archives of a text-heavy SQLite database compress small — retention is cheap, and it's what saves you when you only notice a problem a week later.
2. One sync job for offsite
The staging directory is mirrored to cloud storage with rclone — ours goes to Backblaze B2, but the same line works with S3, R2, Google Drive, or a friend's NAS over SFTP:
rclone sync "$HOME/backups/staged" b2:your-bucket/backups/ \
--transfers 4 --fast-list -v Because every app stages into the same directory, offsite is one job, not one per app. rclone sync mirrors deletions too, so the 30-day retention propagates to the bucket instead of growing forever.
3. Schedule it and make failures loud
A runner script executes every job in the jobs directory, then the sync. Schedule it nightly — cron on Linux:
0 3 * * * $HOME/home-server/backups/runner.sh or a LaunchAgent on macOS (StartCalendarInterval with Hour = 3). The part that matters more than the schedule: a backup system that fails silently is a time bomb. Our runner posts to ntfy.sh on failure, so a broken job is a phone notification, not a discovery during a restore:
curl -s \
-H "Title: Backup failed: lector" \
-H "Priority: high" \
-d "Job exited non-zero — check the log" \
"https://ntfy.sh/your-private-topic" Restoring
# 1. Stop Lector
docker compose stop lector
# 2. Move the broken data dir aside (never delete until verified)
mv ./data ./data.broken
# 3. Unpack the archive
mkdir -p ./data
tar -xzf ~/backups/staged/lector/lector-20260708-030000.tar.gz -C ./data
# 4. Start and verify
docker compose start lector Open the app and check your library and vocabulary are there. Then — and only then — remove data.broken.
Do a restore drill once. Unpack a backup into a scratch directory, point a second Lector container at it, confirm your data is really in there. A backup you've never restored is a hope, not a backup.