CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
backup-offsite.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.
| 1a07e02 | 1 | #!/usr/bin/env bash |
| 2 | # | |
| 3 | # Durable offsite backup for the self-hosted VPS (systemd + Neon, NOT docker). | |
| 4 | # | |
| 5 | # This is the backup that lets Gluecron drop the GitHub mirror safely: it is | |
| 6 | # the ONLY off-box copy of the bare git repos once GitHub is gone. It captures | |
| 7 | # both halves of the platform's state: | |
| 8 | # | |
| 9 | # 1. the bare git repos under $GIT_REPOS_PATH (the actual repo objects — | |
| 10 | # the DB only stores metadata and CANNOT reconstruct these) | |
| 11 | # 2. the Neon Postgres database via pg_dump over $DATABASE_URL | |
| 12 | # | |
| 13 | # ...then pushes both to an offsite bucket with rclone and pings a | |
| 14 | # dead-man's-switch so a silently-missed backup pages you. | |
| 15 | # | |
| 16 | # Install as a systemd timer (see docs/CUTOVER_RUNBOOK.md). Run daily. | |
| 17 | # | |
| 18 | # Required env (set in /etc/gluecron.env): | |
| 19 | # DATABASE_URL Neon connection string (postgresql://...) | |
| 20 | # BACKUP_RCLONE_REMOTE e.g. r2:gluecron-backups (rclone must be configured) | |
| 21 | # Optional env: | |
| 22 | # GIT_REPOS_PATH bare repos dir (default: /opt/gluecron/repos) | |
| 23 | # BACKUP_DIR local staging dir (default: /opt/gluecron/backups) | |
| 24 | # RETAIN_DAYS local + offsite retention (default: 14) | |
| 25 | # HEALTHCHECK_PING_URL https://hc-ping.com/<uuid> (healthchecks.io) | |
| 26 | set -euo pipefail | |
| 27 | ||
| 28 | GIT_REPOS_PATH="${GIT_REPOS_PATH:-/opt/gluecron/repos}" | |
| 29 | BACKUP_DIR="${BACKUP_DIR:-/opt/gluecron/backups}" | |
| 30 | RETAIN_DAYS="${RETAIN_DAYS:-14}" | |
| 31 | ||
| 32 | log() { echo "$(date -Is) [backup-offsite] $*"; } | |
| 33 | ||
| 34 | if [ -z "${DATABASE_URL:-}" ]; then | |
| 35 | log "FATAL: DATABASE_URL is not set — cannot back up the database." | |
| 36 | exit 1 | |
| 37 | fi | |
| 38 | ||
| 39 | mkdir -p "$BACKUP_DIR" | |
| 40 | ts=$(date +%Y%m%d-%H%M%S) | |
| 41 | repos_out="$BACKUP_DIR/repos-$ts.tar.gz" | |
| 42 | db_out="$BACKUP_DIR/db-$ts.dump" | |
| 43 | ||
| 44 | # 1. Bare git repos — the irreplaceable half. tar the whole tree; bare repos | |
| 45 | # are self-contained so a plain tar is a valid, restorable snapshot. | |
| 46 | if [ -d "$GIT_REPOS_PATH" ]; then | |
| 47 | tar -czf "$repos_out" -C "$(dirname "$GIT_REPOS_PATH")" "$(basename "$GIT_REPOS_PATH")" | |
| 48 | log "repos snapshot: $repos_out ($(du -h "$repos_out" | cut -f1))" | |
| 49 | else | |
| 50 | log "WARN: GIT_REPOS_PATH ($GIT_REPOS_PATH) does not exist — skipping repos snapshot" | |
| 51 | fi | |
| 52 | ||
| 53 | # 2. Database — custom format so pg_restore can do selective/parallel restore. | |
| 54 | pg_dump --format=custom --no-owner --no-privileges "$DATABASE_URL" > "$db_out" | |
| 55 | log "db dump: $db_out ($(du -h "$db_out" | cut -f1))" | |
| 56 | ||
| 57 | # 3. Offsite copy (both artifacts). Fail LOUD if configured but the copy fails — | |
| 58 | # a backup that only lives on the same box it protects is not a backup. | |
| 59 | if [ -n "${BACKUP_RCLONE_REMOTE:-}" ]; then | |
| 60 | if ! command -v rclone >/dev/null 2>&1; then | |
| 61 | log "FATAL: BACKUP_RCLONE_REMOTE set but rclone is not installed" | |
| 62 | exit 1 | |
| 63 | fi | |
| 64 | rclone copy "$db_out" "$BACKUP_RCLONE_REMOTE/db/" \ | |
| 65 | && log "offsite: db -> $BACKUP_RCLONE_REMOTE/db/" \ | |
| 66 | || { log "FATAL: offsite db copy failed"; exit 1; } | |
| 67 | if [ -f "$repos_out" ]; then | |
| 68 | rclone copy "$repos_out" "$BACKUP_RCLONE_REMOTE/repos/" \ | |
| 69 | && log "offsite: repos -> $BACKUP_RCLONE_REMOTE/repos/" \ | |
| 70 | || { log "FATAL: offsite repos copy failed"; exit 1; } | |
| 71 | fi | |
| 72 | # Prune offsite copies older than retention (best-effort). | |
| 73 | rclone delete --min-age "${RETAIN_DAYS}d" "$BACKUP_RCLONE_REMOTE/db/" 2>/dev/null || true | |
| 74 | rclone delete --min-age "${RETAIN_DAYS}d" "$BACKUP_RCLONE_REMOTE/repos/" 2>/dev/null || true | |
| 75 | else | |
| 76 | log "WARN: BACKUP_RCLONE_REMOTE not set — backup is LOCAL ONLY (not safe as the sole copy once GitHub is gone)" | |
| 77 | fi | |
| 78 | ||
| 79 | # 4. Local retention. | |
| 80 | find "$BACKUP_DIR" -name 'repos-*.tar.gz' -mtime +"$RETAIN_DAYS" -delete 2>/dev/null || true | |
| 81 | find "$BACKUP_DIR" -name 'db-*.dump' -mtime +"$RETAIN_DAYS" -delete 2>/dev/null || true | |
| 82 | ||
| 83 | # 5. Dead-man's-switch: only ping on full success (reached the end of the script). | |
| 84 | if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then | |
| 85 | curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true | |
| 86 | fi | |
| 87 | ||
| 88 | log "done." |