1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | #!/usr/bin/env bash
#
# Durable offsite backup for the self-hosted VPS (systemd + Neon, NOT docker).
#
# This is the backup that lets Gluecron drop the GitHub mirror safely: it is
# the ONLY off-box copy of the bare git repos once GitHub is gone. It captures
# both halves of the platform's state:
#
# 1. the bare git repos under $GIT_REPOS_PATH (the actual repo objects —
# the DB only stores metadata and CANNOT reconstruct these)
# 2. the Neon Postgres database via pg_dump over $DATABASE_URL
#
# ...then pushes both to an offsite bucket with rclone and pings a
# dead-man's-switch so a silently-missed backup pages you.
#
# Install as a systemd timer (see docs/CUTOVER_RUNBOOK.md). Run daily.
#
# Required env (set in /etc/gluecron.env):
# DATABASE_URL Neon connection string (postgresql://...)
# BACKUP_RCLONE_REMOTE e.g. r2:gluecron-backups (rclone must be configured)
# GIT_REPOS_PATH bare repos dir. REQUIRED IN PRACTICE: the default
# below (/opt/gluecron/repos) does NOT exist on the
# live host, where the repos are in a docker volume at
# /var/lib/docker/volumes/gluecron_git-repos/_data.
# The app reads this from /opt/gluecron/.env, but a
# systemd unit for THIS script reads
# /etc/gluecron.env, so it must be set there too.
# Optional env:
# BACKUP_DIR local staging dir (default: /opt/gluecron/backups)
# RETAIN_DAYS local + offsite retention (default: 14)
# HEALTHCHECK_PING_URL https://hc-ping.com/<uuid> (healthchecks.io)
#
# Escape hatches — all default OFF, because each one makes the backup weaker
# than it looks. Set deliberately, never to quiet a failure:
# ALLOW_DB_ONLY_BACKUP=1 proceed with no repos snapshot
# ALLOW_EMPTY_REPOS_BACKUP=1 proceed when the repos dir holds no repos
# ALLOW_LOCAL_ONLY_BACKUP=1 proceed with no offsite destination
set -euo pipefail
GIT_REPOS_PATH="${GIT_REPOS_PATH:-/opt/gluecron/repos}"
BACKUP_DIR="${BACKUP_DIR:-/opt/gluecron/backups}"
RETAIN_DAYS="${RETAIN_DAYS:-14}"
log() { echo "$(date -Is) [backup-offsite] $*"; }
if [ -z "${DATABASE_URL:-}" ]; then
log "FATAL: DATABASE_URL is not set — cannot back up the database."
exit 1
fi
mkdir -p "$BACKUP_DIR"
ts=$(date +%Y%m%d-%H%M%S)
repos_included=0
repos_out="$BACKUP_DIR/repos-$ts.tar.gz"
db_out="$BACKUP_DIR/db-$ts.dump"
# 1. Bare git repos — the irreplaceable half. tar the whole tree; bare repos
# are self-contained so a plain tar is a valid, restorable snapshot.
#
# A missing or empty repos path is FATAL, not a warning. It used to log a
# WARN and carry on: the tarball was then never created, the `-f` guard on
# the upload below skipped it silently, and the run still pinged the
# dead-man's-switch. The result was a green healthcheck next to a DB-only
# backup — and the DB stores metadata ONLY, so it cannot reconstruct a
# single repository. That is the exact failure this script exists to
# prevent, and it would have looked like success indefinitely.
#
# Not hypothetical: the default below is /opt/gluecron/repos, which does
# not exist on the live host — the repos live in a docker volume. The
# correct GIT_REPOS_PATH is set in /opt/gluecron/.env, but this script's
# own header tells you to configure it in /etc/gluecron.env, where it is
# absent. Installing the timer as documented would have produced exactly
# the silent DB-only backup described above.
if [ ! -d "$GIT_REPOS_PATH" ]; then
if [ "${ALLOW_DB_ONLY_BACKUP:-0}" = "1" ]; then
log "WARN: GIT_REPOS_PATH ($GIT_REPOS_PATH) does not exist — DB-only backup (ALLOW_DB_ONLY_BACKUP=1)"
repos_included=0
else
log "FATAL: GIT_REPOS_PATH ($GIT_REPOS_PATH) does not exist."
log " The database alone CANNOT reconstruct the repositories."
log " Set GIT_REPOS_PATH, or set ALLOW_DB_ONLY_BACKUP=1 to accept a DB-only backup."
exit 1
fi
else
# An existing but WRONG path is the nastier case: tar succeeds and uploads a
# valid, near-empty archive, which looks healthier than an outright failure.
# Bare repos are laid out as <owner>/<repo>.git, so count those.
repo_count=$(find "$GIT_REPOS_PATH" -maxdepth 2 -type d -name '*.git' 2>/dev/null | wc -l | tr -d ' ')
if [ "$repo_count" -eq 0 ]; then
# Fall back to detecting bare repos by their HEAD file, in case a repo is
# stored without the .git suffix.
repo_count=$(find "$GIT_REPOS_PATH" -maxdepth 3 -type f -name HEAD 2>/dev/null | wc -l | tr -d ' ')
fi
if [ "$repo_count" -eq 0 ] && [ "${ALLOW_EMPTY_REPOS_BACKUP:-0}" != "1" ]; then
log "FATAL: no bare repositories found under $GIT_REPOS_PATH."
log " This is almost always the wrong path rather than a genuinely"
log " empty host — on the live box the repos are in a docker volume."
log " Set ALLOW_EMPTY_REPOS_BACKUP=1 if this host really has none."
exit 1
fi
tar -czf "$repos_out" -C "$(dirname "$GIT_REPOS_PATH")" "$(basename "$GIT_REPOS_PATH")"
log "repos snapshot: $repos_out ($(du -h "$repos_out" | cut -f1), $repo_count repos)"
repos_included=1
fi
# 2. Database — custom format so pg_restore can do selective/parallel restore.
pg_dump --format=custom --no-owner --no-privileges "$DATABASE_URL" > "$db_out"
log "db dump: $db_out ($(du -h "$db_out" | cut -f1))"
# 3. Offsite copy (both artifacts). Fail LOUD if configured but the copy fails —
# a backup that only lives on the same box it protects is not a backup.
if [ -n "${BACKUP_RCLONE_REMOTE:-}" ]; then
if ! command -v rclone >/dev/null 2>&1; then
log "FATAL: BACKUP_RCLONE_REMOTE set but rclone is not installed"
exit 1
fi
rclone copy "$db_out" "$BACKUP_RCLONE_REMOTE/db/" \
&& log "offsite: db -> $BACKUP_RCLONE_REMOTE/db/" \
|| { log "FATAL: offsite db copy failed"; exit 1; }
if [ "$repos_included" = "1" ]; then
# Explicit flag rather than a bare `-f` test: a missing tarball we EXPECTED
# to have is a bug, and the old `-f` guard turned exactly that into a
# silent skip that still reported success.
if [ ! -f "$repos_out" ]; then
log "FATAL: repos snapshot $repos_out is missing after being created"
exit 1
fi
rclone copy "$repos_out" "$BACKUP_RCLONE_REMOTE/repos/" \
&& log "offsite: repos -> $BACKUP_RCLONE_REMOTE/repos/" \
|| { log "FATAL: offsite repos copy failed"; exit 1; }
fi
# Prune offsite copies older than retention (best-effort).
rclone delete --min-age "${RETAIN_DAYS}d" "$BACKUP_RCLONE_REMOTE/db/" 2>/dev/null || true
rclone delete --min-age "${RETAIN_DAYS}d" "$BACKUP_RCLONE_REMOTE/repos/" 2>/dev/null || true
elif [ "${ALLOW_LOCAL_ONLY_BACKUP:-0}" = "1" ]; then
log "WARN: BACKUP_RCLONE_REMOTE not set — backup is LOCAL ONLY (ALLOW_LOCAL_ONLY_BACKUP=1)"
else
# Same class of lie as the skipped repos snapshot: a backup sitting on the
# box it is meant to protect is not a backup, and pinging the
# dead-man's-switch for one tells you durability is fine when it is not.
log "FATAL: BACKUP_RCLONE_REMOTE is not set — this backup would be LOCAL ONLY."
log " Set it, or set ALLOW_LOCAL_ONLY_BACKUP=1 to accept a local-only run."
exit 1
fi
# 4. Local retention.
find "$BACKUP_DIR" -name 'repos-*.tar.gz' -mtime +"$RETAIN_DAYS" -delete 2>/dev/null || true
find "$BACKUP_DIR" -name 'db-*.dump' -mtime +"$RETAIN_DAYS" -delete 2>/dev/null || true
# 5. Dead-man's-switch: only ping on full success (reached the end of the script).
if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then
curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true
fi
log "done."
|