From 3ac81a420655d44468a83e9aa4c0c4f2e68841eb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 19:53:09 -0500 Subject: [PATCH] feat(ops): nightly backup script with point-in-time content history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/backup.sh — atomic SQLite .backup + hard-linked point-in-time content snapshots, daily (7) + monthly (12) retention, and an error log. Env-configurable (SCREENTINKER_DIR/BACKUP_DIR/DB/UPLOADS/*_KEEP*) so any self-hoster can use it; defaults target a /opt/screentinker install. Hardens two real failure modes found in production: - Content snapshots EXCLUDE uploads/screenshots/ and use rsync --link-dest instead of cp -al. The per-device *_latest.jpg screenshots are rewritten 24/7; cp -al aborts when a file mutates mid-copy and the prior script swallowed the error with 2>/dev/null, silently breaking content snapshots for ~8 weeks. rsync --link-dest hard-links unchanged files but tolerates in-flight changes; errors now go to backup.log. - Retention sorts by NAME, not mtime: rsync -a / cp -al preserve the source dir's (frozen) mtime, so ls -dt treated fresh snapshots as oldest and pruned them. The timestamp is in the dir name, so name-sort is chronological. README Backups section documents the cron setup + env knobs. Verified on prod. --- README.md | 20 ++++++++++-- scripts/backup.sh | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100755 scripts/backup.sh diff --git a/README.md b/README.md index f6ef398..60dc113 100644 --- a/README.md +++ b/README.md @@ -345,14 +345,28 @@ Your database, uploads, and configuration are preserved — only code files are ### Backups -The SQLite database is at `server/db/remote_display.db`. Back it up regularly: +The SQLite database is at `server/db/remote_display.db` and uploaded content is in +`server/uploads/`. For a one-off DB copy (safe while the server runs): ```bash -# Safe backup (works even while the server is running) sqlite3 server/db/remote_display.db ".backup /path/to/backup.db" ``` -Uploaded content is in `server/uploads/`. Back that up too. +**Recommended: nightly automated backups** via `scripts/backup.sh`. It takes an +atomic DB snapshot plus a hard-linked, point-in-time copy of your content (durable +images/videos; ephemeral per-device screenshots are excluded), with daily + monthly +retention and an error log. Add a cron entry: + +```bash +# as root (or your service user) — adjust the path to your install +0 3 * * * /opt/screentinker/scripts/backup.sh +``` + +Override defaults with env vars if your layout differs: +`SCREENTINKER_DIR` (default `/opt/screentinker`), `BACKUP_DIR`, `DB`, `UPLOADS`, +`DAILY_KEEP` (7), `MONTHLY_KEEP` (12), `DB_KEEP_DAYS` (30). Backups land in +`$BACKUP_DIR` (`remote_display-.db`, `content-latest/`, `content-/`, +`content-monthly-/`) and each run appends to `$BACKUP_DIR/backup.log`. ### Admin Recovery diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..5b33e2d --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# ScreenTinker backup - nightly DB + content backup with point-in-time history. +# +# Install (self-hosters): +# 1. Set SCREENTINKER_DIR if your install isn't at /opt/screentinker. +# 2. Add a root (or service-user) cron entry, e.g.: +# 0 3 * * * /opt/screentinker/scripts/backup.sh +# 3. Restore with: sqlite3 .backup files copy straight back; +# cp -a backups/content-/ server/uploads/ +# +# What it keeps in $BACKUP_DIR: +# remote_display-.db atomic SQLite snapshot (kept $DB_KEEP_DAYS days) +# content-latest/ live mirror of uploads/ (current-state recovery) +# content-/ daily point-in-time copy (kept newest $DAILY_KEEP) +# content-monthly-/ long-horizon keep (kept newest $MONTHLY_KEEP) +# backup.log run log (errors are recorded, not swallowed) +# +# Design notes (learned the hard way): +# - Content snapshots EXCLUDE uploads/screenshots/: per-device *_latest.jpg files +# are rewritten 24/7, and `cp -al` aborts when a file mutates mid-copy. That race +# silently broke snapshots in one deployment for ~8 weeks. rsync --link-dest below +# hard-links unchanged files (cheap, like cp -al) but tolerates in-flight changes. +# - Retention sorts by NAME, not mtime: rsync -a / cp -al preserve the source dir's +# (often frozen) mtime, so `ls -dt` would treat a fresh snapshot as oldest and +# prune it. The timestamp is in the dir name, so name-sort is chronological. + +set -o pipefail +APP_DIR="${SCREENTINKER_DIR:-/opt/screentinker}" +BACKUP_DIR="${BACKUP_DIR:-$APP_DIR/backups}" +DB="${DB:-$APP_DIR/server/db/remote_display.db}" +UPLOADS="${UPLOADS:-$APP_DIR/server/uploads}" +DAILY_KEEP="${DAILY_KEEP:-7}" +MONTHLY_KEEP="${MONTHLY_KEEP:-12}" +DB_KEEP_DAYS="${DB_KEEP_DAYS:-30}" + +LOG="$BACKUP_DIR/backup.log" +TIMESTAMP="$(date +%Y%m%d-%H%M)" +MONTH="$(date +%Y%m)" +mkdir -p "$BACKUP_DIR" +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } +log "=== backup start $TIMESTAMP (app=$APP_DIR) ===" + +# 1) Atomic DB backup (safe while the server is running). +if sqlite3 "$DB" ".backup '$BACKUP_DIR/remote_display-$TIMESTAMP.db'" 2>>"$LOG"; then + log "db backup ok: remote_display-$TIMESTAMP.db" +else + log "ERROR: db backup failed (exit $?)" +fi + +# 2) Refresh the live content mirror (full, incl. screenshots - current-state DR). +if rsync -a --delete "$UPLOADS/" "$BACKUP_DIR/content-latest/" 2>>"$LOG"; then + touch "$BACKUP_DIR/content-latest" # rsync -a leaves the dir mtime frozen; correct it + log "content-latest mirror refreshed" +else + log "ERROR: content-latest rsync failed (exit $?)" +fi + +# 3) Point-in-time content snapshot (hard-linked, screenshots excluded - see notes). +SNAP="$BACKUP_DIR/content-$TIMESTAMP" +rsync -a --link-dest="$BACKUP_DIR/content-latest" --exclude='/screenshots/' \ + "$BACKUP_DIR/content-latest/" "$SNAP/" 2>>"$LOG" +rc=$? +if { [ "$rc" -eq 0 ] || [ "$rc" -eq 24 ]; } && [ -d "$SNAP" ]; then + touch "$SNAP" + log "content snapshot ok: content-$TIMESTAMP ($(find "$SNAP" -type f | wc -l) files, rc=$rc)" + MONTHLY="$BACKUP_DIR/content-monthly-$MONTH" + if [ ! -d "$MONTHLY" ]; then + cp -al "$SNAP" "$MONTHLY" 2>>"$LOG" && touch "$MONTHLY" && log "monthly keep created: content-monthly-$MONTH" + fi +else + log "ERROR: content snapshot failed (rsync exit $rc) - see above" +fi + +# 4) Retention (name-sorted = chronological; see notes). +find "$BACKUP_DIR" -maxdepth 1 -name "remote_display-*.db" -mtime +"$DB_KEEP_DAYS" -delete 2>>"$LOG" +ls -d "$BACKUP_DIR"/content-2* 2>/dev/null | sort | head -n -"$DAILY_KEEP" | xargs -r rm -rf 2>>"$LOG" +ls -d "$BACKUP_DIR"/content-monthly-* 2>/dev/null | sort | head -n -"$MONTHLY_KEEP" | xargs -r rm -rf 2>>"$LOG" + +log "=== backup done $TIMESTAMP ==="