From 088b3b2f5cdb09eaa6aeacdcae149cee4525469f Mon Sep 17 00:00:00 2001 From: Marco Mondini <10319061+mondial7@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:31:37 +0200 Subject: [PATCH] ops(#35): database backup & restore scripts + DR guide - scripts/backup.sh: timestamped, compressed pg_dump (custom format) with retention pruning; scripts/restore.sh: pg_restore with --clean --if-exists. Both wrap the standard PG client tools and validate DATABASE_URL / tool presence. - make backup target; 'Backups & disaster recovery' section in the deployment guide (nightly cron, Docker Compose variant, restore drill, off-host reminder). - Backup+restore flow validated end-to-end against a real Postgres. All state is in Postgres (the binary is stateless), so a DB dump is a full backup. --- CHANGELOG.md | 7 +++++++ Makefile | 4 ++++ SECURITY.md | 3 ++- docs/deployment-production.md | 38 +++++++++++++++++++++++++++++++++++ scripts/backup.sh | 37 ++++++++++++++++++++++++++++++++++ scripts/restore.sh | 28 ++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 1 deletion(-) create mode 100755 scripts/backup.sh create mode 100755 scripts/restore.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6867328..1fc3bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- **Backups & disaster recovery** (#35) — `scripts/backup.sh` (timestamped, + compressed `pg_dump` with retention) and `scripts/restore.sh`, a `make backup` + target, and a DR section in the deployment guide (cron example, Docker variant, + restore drill). All state is in Postgres, so a DB dump is a full backup. + ## [1.2.0] - 2026-07-11 ### Added diff --git a/Makefile b/Makefile index 4331cec..93dc499 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ help: @echo " vet fmt go vet / gofmt" @echo " dist Cross-compile bare release binaries into $(DIST_DIR)/" @echo " release Cross-compile + tar.gz + SHA256SUMS (VERSION=vX.Y.Z)" + @echo " backup Dump the database via scripts/backup.sh (needs DATABASE_URL)" @echo " docker-up Start the Postgres dependency via docker compose" @echo " clean Remove build artifacts" @@ -82,6 +83,9 @@ release: clean @cd $(DIST_DIR) && shasum -a 256 *.tar.gz > $(BINARY)-$(VERSION)-SHA256SUMS.txt @echo "==> Wrote $(DIST_DIR)/$(BINARY)-$(VERSION)-SHA256SUMS.txt" +backup: + ./scripts/backup.sh + docker-up: docker compose up -d postgres diff --git a/SECURITY.md b/SECURITY.md index 4d8f2d4..54dcdec 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -171,6 +171,7 @@ Things the application can't enforce on your behalf: - [ ] Set `ADMIN_EMAIL` to the owner's address before first sign-in. - [ ] The app rate-limits per IP already; for extra depth, also throttle at the reverse proxy (`rate_limit` in Caddy, `limit_req` in nginx). -- [ ] Schedule an off-host `pg_dump` backup. +- [ ] Schedule an off-host backup with `scripts/backup.sh` (see the deployment + guide's "Backups & disaster recovery") and test a restore periodically. - [ ] **Never** set `DEV_MODE=true` in production — it unlocks `dev-login` and relaxes the Secure cookie flag. diff --git a/docs/deployment-production.md b/docs/deployment-production.md index 89dacc1..4258b6a 100644 --- a/docs/deployment-production.md +++ b/docs/deployment-production.md @@ -280,6 +280,44 @@ AI data flow, and the full hardening checklist. Not covered here: --- +## Backups & disaster recovery + +All state lives in PostgreSQL — the app itself is stateless (templates and assets +are baked into the binary). So a database backup is a full backup. + +The repo ships two helpers that wrap `pg_dump`/`pg_restore`: + +```bash +# Back up to ./backups (compressed custom-format dump), pruning dumps > 14 days. +DATABASE_URL=postgres://smart360:@localhost:5432/smart360 ./scripts/backup.sh + +# Restore a dump into a target database (drops & recreates objects). +DATABASE_URL=postgres://smart360:@localhost:5432/smart360 \ + ./scripts/restore.sh backups/smart360-YYYYMMDDTHHMMSSZ.dump +``` + +Schedule the backup nightly and **ship the dumps off-host** — they contain all +feedback data. A cron example: + +```cron +# /etc/cron.d/smart360-backup (runs 02:30 daily as the smart360 user) +30 2 * * * smart360 DATABASE_URL='postgres://smart360:@localhost:5432/smart360' BACKUP_DIR=/var/backups/smart360 RETENTION_DAYS=14 /usr/local/lib/smart360/backup.sh >> /var/log/smart360-backup.log 2>&1 +``` + +Docker Compose users can run the same dump against the `postgres` service: + +```bash +docker compose -f docker-compose.prod.yml exec -T postgres \ + pg_dump --format=custom --no-owner --no-privileges -U smart360 smart360 \ + > "smart360-$(date -u +%Y%m%dT%H%M%SZ).dump" +``` + +Recovery drill: after restoring into a fresh database and pointing `DATABASE_URL` +at it, start the app — it re-applies any pending migrations on boot. **Test a +restore periodically**; an untested backup is a guess, not a recovery plan. + +--- + ## Operational notes ### Updating to a new version diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..2a560cb --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# +# Back up the Smart 360 PostgreSQL database to a timestamped, compressed +# custom-format dump, then prune old dumps. +# +# Usage: +# DATABASE_URL=postgres://user:pass@host:5432/smart360 ./scripts/backup.sh +# +# Env: +# DATABASE_URL required — the Postgres connection string +# BACKUP_DIR output directory (default: ./backups) +# RETENTION_DAYS prune dumps older than this many days (default: 14) +# +# Restore a dump with scripts/restore.sh. Ship dumps off-host (they contain all +# feedback data) and test a restore periodically — an untested backup is a guess. +set -euo pipefail + +: "${DATABASE_URL:?set DATABASE_URL to the Postgres connection string}" +BACKUP_DIR="${BACKUP_DIR:-./backups}" +RETENTION_DAYS="${RETENTION_DAYS:-14}" + +command -v pg_dump >/dev/null 2>&1 || { + echo "pg_dump not found — install the PostgreSQL client tools." >&2 + exit 1 +} + +mkdir -p "$BACKUP_DIR" +ts="$(date -u +%Y%m%dT%H%M%SZ)" +out="$BACKUP_DIR/smart360-${ts}.dump" + +echo "Backing up to ${out}" +pg_dump --format=custom --no-owner --no-privileges --file="$out" "$DATABASE_URL" +echo "Wrote ${out} ($(du -h "$out" | cut -f1))" + +# Prune old dumps (best-effort). +find "$BACKUP_DIR" -maxdepth 1 -name 'smart360-*.dump' -type f -mtime "+${RETENTION_DAYS}" -print -delete \ + | sed 's/^/Pruned: /' || true diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..866d04c --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# +# Restore a Smart 360 PostgreSQL dump produced by scripts/backup.sh. +# +# Usage: +# DATABASE_URL=postgres://user:pass@host:5432/smart360 \ +# ./scripts/restore.sh backups/smart360-YYYYMMDDTHHMMSSZ.dump +# +# WARNING: this drops and recreates the objects in the target database. Point it +# at a fresh/empty database for disaster recovery, or knowingly overwrite. +set -euo pipefail + +: "${DATABASE_URL:?set DATABASE_URL to the target Postgres connection string}" +dump="${1:?usage: restore.sh }" + +[ -f "$dump" ] || { echo "dump not found: $dump" >&2; exit 1; } +command -v pg_restore >/dev/null 2>&1 || { + echo "pg_restore not found — install the PostgreSQL client tools." >&2 + exit 1 +} + +echo "About to restore ${dump}" +echo " into ${DATABASE_URL%%\?*}" +echo " (existing objects will be dropped and recreated). Ctrl-C within 5s to abort." +sleep 5 + +pg_restore --clean --if-exists --no-owner --no-privileges --dbname="$DATABASE_URL" "$dump" +echo "Restore complete. The app re-applies any pending migrations on next start."