From 9ac3342664a8d57c2e5108aaeb3feadecf6cec63 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Mon, 5 Oct 2026 19:14:22 -0400 Subject: [PATCH] Litestream replication to the NAS and nightly media backup Add a litestream service (0.5.17) to the compose stack that streams /data/mealquest.db over SFTP to the Synology NAS with a dedicated key, daily snapshots kept for a week. Add a systemd timer that rsyncs the media directory to the NAS nightly at 03:30. Task: d2f2e87e-5114-4ef1-93ab-360ffb5c009e Co-Authored-By: Claude Opus 5.5 --- deploy/compose.yaml | 36 +++++++++++++++++ deploy/litestream.yml | 31 ++++++++++++++ deploy/systemd/mealquest-media-backup.service | 13 ++++++ deploy/systemd/mealquest-media-backup.sh | 40 +++++++++++++++++++ deploy/systemd/mealquest-media-backup.timer | 11 +++++ 5 files changed, 131 insertions(+) create mode 100644 deploy/litestream.yml create mode 100644 deploy/systemd/mealquest-media-backup.service create mode 100755 deploy/systemd/mealquest-media-backup.sh create mode 100644 deploy/systemd/mealquest-media-backup.timer diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 377e580..6ec07bb 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -46,6 +46,42 @@ services: cap_drop: - ALL + # Litestream streams the database to the Synology NAS over SFTP + # (config: deploy/litestream.yml). The SFTP private key + # deploy/secrets/litestream_key is a dedicated keypair, owned by + # 10001:10001 with mode 0600 like the other secrets. + # + # The data volume is mounted read-write on purpose: Litestream writes its + # tracking tables into the database and checkpoints the WAL. It runs as + # the app's own user (owner of the volume) with a read-only root + # filesystem, no privilege escalation and no Linux capabilities. + # + # Restore (app stopped; --no-deps so the app does not start and create a + # fresh database first): + # docker compose -f deploy/compose.yaml run --rm --no-deps litestream \ + # restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db + litestream: + image: litestream/litestream:0.5.17 + user: "10001:10001" + restart: unless-stopped + command: ["replicate", "-config", "/etc/litestream.yml"] + depends_on: + mealquest: + condition: service_healthy + volumes: + - mealquest-data:/data + - ./litestream.yml:/etc/litestream.yml:ro + - ./secrets/litestream_key:/etc/litestream/sftp_key:ro + networks: + - mealquest + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + secrets: secret_key: file: ./secrets/secret_key diff --git a/deploy/litestream.yml b/deploy/litestream.yml new file mode 100644 index 0000000..a825cea --- /dev/null +++ b/deploy/litestream.yml @@ -0,0 +1,31 @@ +# Litestream continuous backup of the MealQuest SQLite database (Feature +# 07). The ``litestream`` compose service mounts the same named volume as +# the app and streams every committed change to the Synology NAS over +# SFTP, so the backup lives outside the Docker host. +# +# Nothing here is secret. The SFTP private key is a dedicated, git-ignored +# keypair (deploy/secrets/litestream_key) mounted read-only by compose at +# /etc/litestream/sftp_key; its public half is in +# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the NAS's +# ECDSA host key, the algorithm Litestream's SSH client negotiates with +# this NAS. +# +# The Synology SFTP server is chrooted to the shared folders, so the +# directory seen over SSH as /volume1/homes/Joshua/mealquest-backups is +# /homes/Joshua/mealquest-backups over SFTP. + +# Take a full snapshot daily and keep a week of snapshots plus the +# incremental changes between them (point-in-time restore window). +snapshot: + interval: 24h + retention: 168h + +dbs: + - path: /data/mealquest.db + replicas: + - type: sftp + host: 10.60.1.50 + user: joshua + path: /homes/Joshua/mealquest-backups + key-path: /etc/litestream/sftp_key + host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY= diff --git a/deploy/systemd/mealquest-media-backup.service b/deploy/systemd/mealquest-media-backup.service new file mode 100644 index 0000000..2f5d005 --- /dev/null +++ b/deploy/systemd/mealquest-media-backup.service @@ -0,0 +1,13 @@ +# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup). +# Triggered by mealquest-media-backup.timer; run by hand with +# systemctl start mealquest-media-backup +# and read the result with +# journalctl -u mealquest-media-backup +[Unit] +Description=Back up MealQuest media to the Synology NAS +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/mealquest-media-backup diff --git a/deploy/systemd/mealquest-media-backup.sh b/deploy/systemd/mealquest-media-backup.sh new file mode 100755 index 0000000..be9376a --- /dev/null +++ b/deploy/systemd/mealquest-media-backup.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Nightly copy of the MealQuest media directory (recipe and dinner photos) +# to the Synology NAS. The database itself is replicated continuously by +# Litestream; this covers the files beside it. +# +# rsync mirrors the media directory onto .../mealquest-backups/media/ only: +# both paths end in a slash, so --delete removes files from that target +# directory and nothing outside it. A missing source directory (no media +# uploaded yet) is reported and skipped, never synced, so an empty source +# can never wipe the remote copy. Re-runs transfer only what changed. +# +# The volume is writable by the app container, so a media path that is a +# symlink is refused: root's rsync would otherwise follow it and copy +# whatever it points at. Symlinks inside the directory are copied as links +# (-a), never followed. +# +# Connects with the dedicated MealQuest key; the NAS host key must already +# be in root's known_hosts (StrictHostKeyChecking=yes fails otherwise). +# Installed as /usr/local/sbin/mealquest-media-backup; output goes to the +# journal via mealquest-media-backup.service. +set -eu + +src=/var/lib/docker/volumes/mealquest-data/_data/media/ +dest=joshua@10.60.1.50:/volume1/homes/Joshua/mealquest-backups/media/ +key=/home/overseer/mealquest/deploy/secrets/litestream_key + +if [ -L "${src%/}" ]; then + echo "mealquest-media-backup: ${src%/} is a symlink; refusing to back it up" >&2 + exit 1 +fi + +if [ ! -d "$src" ]; then + echo "mealquest-media-backup: no media directory at $src yet; nothing to back up" + exit 0 +fi + +rsync -a --delete --stats \ + -e "ssh -i $key -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes" \ + "$src" "$dest" +echo "mealquest-media-backup: synced $src to $dest" diff --git a/deploy/systemd/mealquest-media-backup.timer b/deploy/systemd/mealquest-media-backup.timer new file mode 100644 index 0000000..62fe968 --- /dev/null +++ b/deploy/systemd/mealquest-media-backup.timer @@ -0,0 +1,11 @@ +# Nightly trigger for mealquest-media-backup.service. Persistent= runs a +# missed backup at the next boot. +[Unit] +Description=Nightly MealQuest media backup + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true + +[Install] +WantedBy=timers.target