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.