Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
backup-offsite.sh7.6 KB · 155 lines
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."