Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
backup.sh4.2 KB · 96 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
#!/usr/bin/env bash
#
# Daily production-database backup for the standalone/Coolify box.
# Installed as a systemd timer by scripts/standalone-deploy.sh.
#
# WHAT IT BACKS UP (corrected 2026-07-14): the REAL production database, which
# is **Neon** — reached via the app container's own DATABASE_URL. The compose
# also runs an unused local `postgres` container; the previous version of this
# script dumped THAT by mistake, so daily backups were capturing empty/stale
# data. Do not reintroduce `$COMPOSE exec postgres pg_dump`.
#
# HOW: Neon runs Postgres 17, so we dump with an ephemeral `postgres:17`
# container (the box's local client is 16 and refuses a newer server). The
# app's DATABASE_URL on this box is malformed (two connstrings mashed on one
# line — a fragile latent bug), so we take the clean Neon URL up to the second
# "DATABASE_URL=" marker. `${URL%%DATABASE_URL=*}` is a no-op once the .env is
# fixed, so this stays correct either way.
#
# Output: custom-format (`-Fc`) dumps — compressed + restorable selectively via
# pg_restore. Compatible with scripts/restore.sh and scripts/backup-restore-drill.sh.
#
# Optional env (set in /opt/gluecron/.env):
#   BACKUP_RCLONE_REMOTE  e.g. r2:gluecron-backups   (needs rclone configured)
#   BACKUP_SCP_TARGET     e.g. user@100.x.y.z:/backups  (self-owned offsite over Tailscale)
#   HEALTHCHECK_PING_URL  e.g. https://hc-ping.com/<uuid>  (dead-man's-switch)
set -euo pipefail

REPO_DIR="/opt/gluecron"
BACKUP_DIR="$REPO_DIR/backups"
RETAIN_DAYS=14
APP_CONTAINER="gluecron-gluecron-1"
PG_IMAGE="postgres:17-alpine"

cd "$REPO_DIR"
mkdir -p "$BACKUP_DIR"
ts=$(date +%Y%m%d-%H%M%S)
out="$BACKUP_DIR/gluecron-db-$ts.dump"

# Resolve the live DATABASE_URL from the running app, then clean it. Reading it
# from the container (not .env) means the backup always matches what the app
# actually connects to.
raw_url="$(docker exec "$APP_CONTAINER" printenv DATABASE_URL)"
clean_url="${raw_url%%DATABASE_URL=*}"
if [ -z "$clean_url" ]; then
  echo "$(date -Is) FATAL: could not resolve DATABASE_URL from $APP_CONTAINER" >&2
  exit 1
fi

# Dump Neon with a matching-major-version client. Custom format, no owner/ACLs
# (portable across restore targets). Password + URL travel only in container
# env; the pg_dump runs via `sh -c` so $NEONURL expands INSIDE the container
# (host-side expansion would be empty and trip `set -u`).
docker run --rm -e NEONURL="$clean_url" -e OUTFILE="/backups/$(basename "$out")" \
  -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \
  sh -c 'pg_dump "$NEONURL" --format=custom --no-owner --no-privileges -f "$OUTFILE"' \
  2>/tmp/backup-err.$$ || {
    echo "$(date -Is) FATAL: pg_dump failed:" >&2
    cat /tmp/backup-err.$$ >&2
    rm -f /tmp/backup-err.$$ "$out"
    exit 1
  }
rm -f /tmp/backup-err.$$

# Sanity: a valid custom-format dump must list objects. Guards against a
# 0-byte/corrupt file being counted as a good backup.
obj_count="$(docker run --rm -e F="/backups/$(basename "$out")" -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \
  sh -c 'pg_restore --list "$F"' 2>/dev/null | grep -cE 'TABLE DATA|TABLE|SEQUENCE' || true)"
if [ "${obj_count:-0}" -lt 20 ]; then
  echo "$(date -Is) FATAL: dump looks empty ($obj_count objects) — refusing to keep it" >&2
  rm -f "$out"
  exit 1
fi

size="$(du -h "$out" | cut -f1)"

# Retention — keep RETAIN_DAYS of daily dumps.
find "$BACKUP_DIR" -name 'gluecron-db-*.dump' -mtime +$RETAIN_DAYS -delete

# Optional self-owned offsite copy over Tailscale (preferred — no third party).
if [ -n "${BACKUP_SCP_TARGET:-}" ]; then
  scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$out" "$BACKUP_SCP_TARGET/" \
    && echo "$(date -Is) offsite scp ok -> $BACKUP_SCP_TARGET" \
    || echo "$(date -Is) WARN: offsite scp failed"
fi

# Optional rclone offsite copy (if you do use a bucket).
if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null 2>&1; then
  rclone copy "$out" "$BACKUP_RCLONE_REMOTE" || echo "$(date -Is) WARN: rclone copy failed"
fi

# Optional dead-man's-switch heartbeat (alerts if a backup is ever missed).
if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then
  curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true
fi

echo "$(date -Is) backup written: $out ($size, $obj_count objects)"