#!/usr/bin/env bash # Nightly off-VPS backup of Petal's database. # # ./backup-petal.sh # snapshot, compress, push off-box, prune # ./backup-petal.sh --local-only # snapshot + prune, skip the remote push # # Run it from cron on the VPS (see deploy/README.md). The snapshot itself goes # through `petal -backup`, which uses SQLite's VACUUM INTO: one coherent file # including anything still in the WAL, taken without a write lock, so it is # safe against the live running app. That is why this script never touches # petal.db / -wal / -shm directly — copying those three separately can capture # a torn mid-checkpoint state. # # Everything below is overridable from the environment. set -euo pipefail # Stack directory (holds docker-compose.yml and ./data). STACK_DIR="${STACK_DIR:-$HOME/petal}" # Where snapshots land on the VPS before being pushed off-box. Inside ./data so # the container can write it through the existing bind mount. LOCAL_DIR="${LOCAL_DIR:-$STACK_DIR/data/backups}" # Off-VPS destination: millenia over headscale. Empty disables the push. REMOTE_HOST="${REMOTE_HOST:-100.64.0.2}" REMOTE_USER="${REMOTE_USER:-}" REMOTE_DIR="${REMOTE_DIR:-petal-backups}" # Retention, in days, on each side. KEEP_LOCAL_DAYS="${KEEP_LOCAL_DAYS:-7}" KEEP_REMOTE_DAYS="${KEEP_REMOTE_DAYS:-30}" local_only=0 [ "${1:-}" = "--local-only" ] && local_only=1 stamp="$(date -u +%Y%m%dT%H%M%SZ)" name="petal-${stamp}.db" cd "$STACK_DIR" echo ">> snapshotting to data/backups/${name}" # The container writes to its own /data mount; ./data/backups is the same # directory seen from the host. docker compose exec -T petal /app/petal -backup "/data/backups/${name}" snapshot="${LOCAL_DIR}/${name}" [ -s "$snapshot" ] || { echo "snapshot missing or empty: $snapshot" >&2; exit 1; } echo ">> compressing" gzip -9 "$snapshot" archive="${snapshot}.gz" echo " $(du -h "$archive" | cut -f1) ${archive}" if [ "$local_only" -eq 0 ] && [ -n "$REMOTE_HOST" ]; then target="${REMOTE_HOST}" [ -n "$REMOTE_USER" ] && target="${REMOTE_USER}@${REMOTE_HOST}" echo ">> pushing to ${target}:${REMOTE_DIR}/" ssh -o BatchMode=yes "$target" "mkdir -p '${REMOTE_DIR}'" scp -q -o BatchMode=yes "$archive" "${target}:${REMOTE_DIR}/" # Verify by size rather than trusting scp's exit code alone — a truncated # transfer that still exits 0 would leave a backup that only looks fine. local_size="$(stat -c%s "$archive")" remote_size="$(ssh -o BatchMode=yes "$target" "stat -c%s '${REMOTE_DIR}/$(basename "$archive")'")" if [ "$local_size" != "$remote_size" ]; then echo "size mismatch after transfer: local ${local_size}, remote ${remote_size}" >&2 exit 1 fi echo " verified ${remote_size} bytes" echo ">> pruning remote copies older than ${KEEP_REMOTE_DAYS} days" ssh -o BatchMode=yes "$target" \ "find '${REMOTE_DIR}' -name 'petal-*.db.gz' -type f -mtime +${KEEP_REMOTE_DAYS} -delete" elif [ "$local_only" -eq 1 ]; then echo ">> --local-only: skipping the remote push" else echo ">> REMOTE_HOST is empty: skipping the remote push" >&2 fi echo ">> pruning local copies older than ${KEEP_LOCAL_DAYS} days" find "$LOCAL_DIR" -name 'petal-*.db.gz' -type f -mtime "+${KEEP_LOCAL_DAYS}" -delete echo ">> done"