Blame · Line-by-line history
backup.sh
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f635e2f | 1 | #!/usr/bin/env bash |
| 2 | # | |
| 41d6ea3 | 3 | # Daily production-database backup for the standalone/Coolify box. |
| 4 | # Installed as a systemd timer by scripts/standalone-deploy.sh. | |
| 5 | # | |
| 6 | # WHAT IT BACKS UP (corrected 2026-07-14): the REAL production database, which | |
| 7 | # is **Neon** — reached via the app container's own DATABASE_URL. The compose | |
| 8 | # also runs an unused local `postgres` container; the previous version of this | |
| 9 | # script dumped THAT by mistake, so daily backups were capturing empty/stale | |
| 10 | # data. Do not reintroduce `$COMPOSE exec postgres pg_dump`. | |
| 11 | # | |
| 12 | # HOW: Neon runs Postgres 17, so we dump with an ephemeral `postgres:17` | |
| 13 | # container (the box's local client is 16 and refuses a newer server). The | |
| 14 | # app's DATABASE_URL on this box is malformed (two connstrings mashed on one | |
| 15 | # line — a fragile latent bug), so we take the clean Neon URL up to the second | |
| 16 | # "DATABASE_URL=" marker. `${URL%%DATABASE_URL=*}` is a no-op once the .env is | |
| 17 | # fixed, so this stays correct either way. | |
| 18 | # | |
| 19 | # Output: custom-format (`-Fc`) dumps — compressed + restorable selectively via | |
| 20 | # pg_restore. Compatible with scripts/restore.sh and scripts/backup-restore-drill.sh. | |
| f635e2f | 21 | # |
| 22 | # Optional env (set in /opt/gluecron/.env): | |
| 23 | # BACKUP_RCLONE_REMOTE e.g. r2:gluecron-backups (needs rclone configured) | |
| 41d6ea3 | 24 | # BACKUP_SCP_TARGET e.g. user@100.x.y.z:/backups (self-owned offsite over Tailscale) |
| 25 | # HEALTHCHECK_PING_URL e.g. https://hc-ping.com/<uuid> (dead-man's-switch) | |
| f635e2f | 26 | set -euo pipefail |
| 27 | ||
| 28 | REPO_DIR="/opt/gluecron" | |
| 29 | BACKUP_DIR="$REPO_DIR/backups" | |
| 30 | RETAIN_DAYS=14 | |
| 41d6ea3 | 31 | APP_CONTAINER="gluecron-gluecron-1" |
| 32 | PG_IMAGE="postgres:17-alpine" | |
| f635e2f | 33 | |
| 34 | cd "$REPO_DIR" | |
| 35 | mkdir -p "$BACKUP_DIR" | |
| 36 | ts=$(date +%Y%m%d-%H%M%S) | |
| 41d6ea3 | 37 | out="$BACKUP_DIR/gluecron-db-$ts.dump" |
| 38 | ||
| 39 | # Resolve the live DATABASE_URL from the running app, then clean it. Reading it | |
| 40 | # from the container (not .env) means the backup always matches what the app | |
| 41 | # actually connects to. | |
| 42 | raw_url="$(docker exec "$APP_CONTAINER" printenv DATABASE_URL)" | |
| 43 | clean_url="${raw_url%%DATABASE_URL=*}" | |
| 44 | if [ -z "$clean_url" ]; then | |
| 45 | echo "$(date -Is) FATAL: could not resolve DATABASE_URL from $APP_CONTAINER" >&2 | |
| 46 | exit 1 | |
| 47 | fi | |
| f635e2f | 48 | |
| 41d6ea3 | 49 | # Dump Neon with a matching-major-version client. Custom format, no owner/ACLs |
| 50 | # (portable across restore targets). Password + URL travel only in container | |
| 51 | # env; the pg_dump runs via `sh -c` so $NEONURL expands INSIDE the container | |
| 52 | # (host-side expansion would be empty and trip `set -u`). | |
| 53 | docker run --rm -e NEONURL="$clean_url" -e OUTFILE="/backups/$(basename "$out")" \ | |
| 54 | -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \ | |
| 55 | sh -c 'pg_dump "$NEONURL" --format=custom --no-owner --no-privileges -f "$OUTFILE"' \ | |
| 56 | 2>/tmp/backup-err.$$ || { | |
| 57 | echo "$(date -Is) FATAL: pg_dump failed:" >&2 | |
| 58 | cat /tmp/backup-err.$$ >&2 | |
| 59 | rm -f /tmp/backup-err.$$ "$out" | |
| 60 | exit 1 | |
| 61 | } | |
| 62 | rm -f /tmp/backup-err.$$ | |
| f635e2f | 63 | |
| 41d6ea3 | 64 | # Sanity: a valid custom-format dump must list objects. Guards against a |
| 65 | # 0-byte/corrupt file being counted as a good backup. | |
| 66 | obj_count="$(docker run --rm -e F="/backups/$(basename "$out")" -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \ | |
| 67 | sh -c 'pg_restore --list "$F"' 2>/dev/null | grep -cE 'TABLE DATA|TABLE|SEQUENCE' || true)" | |
| 68 | if [ "${obj_count:-0}" -lt 20 ]; then | |
| 69 | echo "$(date -Is) FATAL: dump looks empty ($obj_count objects) — refusing to keep it" >&2 | |
| 70 | rm -f "$out" | |
| 71 | exit 1 | |
| 72 | fi | |
| 73 | ||
| 74 | size="$(du -h "$out" | cut -f1)" | |
| 75 | ||
| 76 | # Retention — keep RETAIN_DAYS of daily dumps. | |
| 77 | find "$BACKUP_DIR" -name 'gluecron-db-*.dump' -mtime +$RETAIN_DAYS -delete | |
| 78 | ||
| 79 | # Optional self-owned offsite copy over Tailscale (preferred — no third party). | |
| 80 | if [ -n "${BACKUP_SCP_TARGET:-}" ]; then | |
| 81 | scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$out" "$BACKUP_SCP_TARGET/" \ | |
| 82 | && echo "$(date -Is) offsite scp ok -> $BACKUP_SCP_TARGET" \ | |
| 83 | || echo "$(date -Is) WARN: offsite scp failed" | |
| 84 | fi | |
| f635e2f | 85 | |
| 41d6ea3 | 86 | # Optional rclone offsite copy (if you do use a bucket). |
| f635e2f | 87 | if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null 2>&1; then |
| 41d6ea3 | 88 | rclone copy "$out" "$BACKUP_RCLONE_REMOTE" || echo "$(date -Is) WARN: rclone copy failed" |
| f635e2f | 89 | fi |
| 90 | ||
| 41d6ea3 | 91 | # Optional dead-man's-switch heartbeat (alerts if a backup is ever missed). |
| f635e2f | 92 | if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then |
| 93 | curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true | |
| 94 | fi | |
| 95 | ||
| 41d6ea3 | 96 | echo "$(date -Is) backup written: $out ($size, $obj_count objects)" |