backup: nightly pg_dump service on the postgres:18 image

Production Postgres had NO automated backup (pg_dump appeared only in the
one-time migration script). New db-backup service in both compose files fires
20:10 ET Mon-Fri: pg_dump | gzip with pipefail + gunzip -t + tmp-then-rename
(a mid-stream death can't masquerade as a backup), prunes past
BACKUP_KEEP_DAYS, writes last_backup_ok on success. postgres:18 image =
version-matched client (app image has none). TrueNAS host is pull-only, so
its copy inlines the script. Restore: gunzip -c dump | psql $DATABASE_URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-13 16:39:26 -04:00
1 parent 5b95a9712c
commit caee1d5655
4 files changed
+107

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Nightly logical backup of the momentum Postgres. Runs inside the db-backup
# compose service — the postgres:18 image, so pg_dump always matches the
# server major (the app image has NO pg_dump, and a Debian-stock client is an
# older major that refuses to dump a newer server).
#
# pg_dump | gzip -> $BACKUP_DIR/momentum-YYYY-MM-DD.sql.gz
# prune anything older than BACKUP_KEEP_DAYS (default 14)
# write $BACKUP_DIR/last_backup_ok on success (freshness marker)
#
# Restore: gunzip -c momentum-<date>.sql.gz | psql "$DATABASE_URL"
#
# Run with bash (not sh): pipefail is what catches a mid-stream pg_dump death
# that would otherwise leave a truncated-but-valid gzip behind.
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/backups}"
BACKUP_KEEP_DAYS="${BACKUP_KEEP_DAYS:-14}"
: "${DATABASE_URL:?DATABASE_URL is required}"
mkdir -p "$BACKUP_DIR"
out="$BACKUP_DIR/momentum-$(date +%F).sql.gz"
echo "[backup] $(date) -> $out"
pg_dump "$DATABASE_URL" | gzip > "$out.tmp"
gunzip -t "$out.tmp"
mv "$out.tmp" "$out"
find "$BACKUP_DIR" -name 'momentum-*.sql.gz*' -mtime +"$BACKUP_KEEP_DAYS" -delete # *: also sweep stale .tmp from failed runs
date > "$BACKUP_DIR/last_backup_ok"
echo "[backup] OK $(du -h "$out" | cut -f1)"
+39
View File
@@ -194,3 +194,42 @@ services:
fi
sleep 20
done
db-backup:
# Nightly pg_dump at 20:10 ET Mon-Fri, on the postgres:18 image so the
# client always matches the server major (the app image has NO pg_dump).
# This host is pull-only (no repo checkout), so the backup logic is INLINE
# — keep it in sync with deploy/docker/backup_db.sh. Restore:
# gunzip -c momentum-<date>.sql.gz | psql "$DATABASE_URL"
image: postgres:18
restart: unless-stopped
environment:
DATABASE_URL: 'postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum'
BACKUP_DIR: '/backups'
BACKUP_KEEP_DAYS: '14'
TZ: 'America/New_York' # postgres image defaults to UTC; the 20:10 gate is ET
volumes:
- /mnt/volume/Apps/momentum/data/backups:/backups
depends_on:
postgres:
condition: service_healthy
command:
- /bin/bash
- -c
- |
set -o pipefail
echo "[backup] up; fires 20:10 ET Mon-Fri"
while true; do
if [ "$$(date +%H:%M)" = "20:10" ] && [ "$$(date +%u)" -le 5 ]; then
out="$$BACKUP_DIR/momentum-$$(date +%F).sql.gz"
mkdir -p "$$BACKUP_DIR" \
&& pg_dump "$$DATABASE_URL" | gzip > "$$out.tmp" \
&& gunzip -t "$$out.tmp" && mv "$$out.tmp" "$$out" \
&& find "$$BACKUP_DIR" -name 'momentum-*.sql.gz*' -mtime +"$$BACKUP_KEEP_DAYS" -delete \
&& date > "$$BACKUP_DIR/last_backup_ok" \
&& echo "[backup] OK $$out" \
|| echo "[backup] FAILED"
sleep 120
fi
sleep 20
done