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
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.

backup.shBlame136 lines · 2 contributors
f635e2fClaude1#!/usr/bin/env bash
2#
5dc0e94ccanty labs3# Daily production backup for the standalone/Coolify box.
41d6ea3ccanty labs4# Installed as a systemd timer by scripts/standalone-deploy.sh.
5#
5dc0e94ccanty labs6# WHAT IT BACKS UP:
7# 1. the REAL production database, which is **Neon** — reached via the app
8# container's own DATABASE_URL. The compose also runs an unused local
9# `postgres` container; an earlier version of this script dumped THAT by
10# mistake (corrected 2026-07-14). Do not reintroduce
11# `$COMPOSE exec postgres pg_dump`.
12# 2. the bare git repos themselves (added 2026-07-15) — these live in the
13# `gluecron_git-repos` docker volume, NOT a plain host directory, and are
14# the irreplaceable half: the DB only stores metadata and cannot
15# reconstruct repo objects. Losing the box without this half means
16# losing every hosted repo, full stop.
41d6ea3ccanty labs17#
5dc0e94ccanty labs18# HOW (DB): Neon runs Postgres 17, so we dump with an ephemeral `postgres:17`
41d6ea3ccanty labs19# container (the box's local client is 16 and refuses a newer server). The
20# app's DATABASE_URL on this box is malformed (two connstrings mashed on one
21# line — a fragile latent bug), so we take the clean Neon URL up to the second
22# "DATABASE_URL=" marker. `${URL%%DATABASE_URL=*}` is a no-op once the .env is
23# fixed, so this stays correct either way.
24#
5dc0e94ccanty labs25# HOW (repos): tar the docker volume's mountpoint directly on the host (bare
26# repos are self-contained, so a plain tar is a valid, restorable snapshot).
27#
28# Output: custom-format (`-Fc`) DB dumps — compressed + restorable selectively
29# via pg_restore. Compatible with scripts/restore.sh and
30# scripts/backup-restore-drill.sh. Repos ship as a gzip tarball.
f635e2fClaude31#
32# Optional env (set in /opt/gluecron/.env):
33# BACKUP_RCLONE_REMOTE e.g. r2:gluecron-backups (needs rclone configured)
41d6ea3ccanty labs34# BACKUP_SCP_TARGET e.g. user@100.x.y.z:/backups (self-owned offsite over Tailscale)
35# HEALTHCHECK_PING_URL e.g. https://hc-ping.com/<uuid> (dead-man's-switch)
5dc0e94ccanty labs36# GIT_REPOS_VOLUME docker volume name for bare repos (default: gluecron_git-repos)
f635e2fClaude37set -euo pipefail
38
39REPO_DIR="/opt/gluecron"
40BACKUP_DIR="$REPO_DIR/backups"
41RETAIN_DAYS=14
41d6ea3ccanty labs42APP_CONTAINER="gluecron-gluecron-1"
43PG_IMAGE="postgres:17-alpine"
5dc0e94ccanty labs44GIT_REPOS_VOLUME="${GIT_REPOS_VOLUME:-gluecron_git-repos}"
f635e2fClaude45
46cd "$REPO_DIR"
47mkdir -p "$BACKUP_DIR"
48ts=$(date +%Y%m%d-%H%M%S)
41d6ea3ccanty labs49out="$BACKUP_DIR/gluecron-db-$ts.dump"
5dc0e94ccanty labs50repos_out="$BACKUP_DIR/gluecron-repos-$ts.tar.gz"
41d6ea3ccanty labs51
52# Resolve the live DATABASE_URL from the running app, then clean it. Reading it
53# from the container (not .env) means the backup always matches what the app
54# actually connects to.
55raw_url="$(docker exec "$APP_CONTAINER" printenv DATABASE_URL)"
56clean_url="${raw_url%%DATABASE_URL=*}"
57if [ -z "$clean_url" ]; then
58 echo "$(date -Is) FATAL: could not resolve DATABASE_URL from $APP_CONTAINER" >&2
59 exit 1
60fi
f635e2fClaude61
41d6ea3ccanty labs62# Dump Neon with a matching-major-version client. Custom format, no owner/ACLs
63# (portable across restore targets). Password + URL travel only in container
64# env; the pg_dump runs via `sh -c` so $NEONURL expands INSIDE the container
65# (host-side expansion would be empty and trip `set -u`).
66docker run --rm -e NEONURL="$clean_url" -e OUTFILE="/backups/$(basename "$out")" \
67 -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \
68 sh -c 'pg_dump "$NEONURL" --format=custom --no-owner --no-privileges -f "$OUTFILE"' \
69 2>/tmp/backup-err.$$ || {
70 echo "$(date -Is) FATAL: pg_dump failed:" >&2
71 cat /tmp/backup-err.$$ >&2
72 rm -f /tmp/backup-err.$$ "$out"
73 exit 1
74 }
75rm -f /tmp/backup-err.$$
f635e2fClaude76
41d6ea3ccanty labs77# Sanity: a valid custom-format dump must list objects. Guards against a
78# 0-byte/corrupt file being counted as a good backup.
79obj_count="$(docker run --rm -e F="/backups/$(basename "$out")" -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \
80 sh -c 'pg_restore --list "$F"' 2>/dev/null | grep -cE 'TABLE DATA|TABLE|SEQUENCE' || true)"
81if [ "${obj_count:-0}" -lt 20 ]; then
82 echo "$(date -Is) FATAL: dump looks empty ($obj_count objects) — refusing to keep it" >&2
83 rm -f "$out"
84 exit 1
85fi
86
87size="$(du -h "$out" | cut -f1)"
88
5dc0e94ccanty labs89# Bare git repos — resolve the volume's real host mountpoint (not a fixed
90# path: docker owns where volumes actually live) and tar it in place.
91repos_mountpoint="$(docker volume inspect "$GIT_REPOS_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null || true)"
92if [ -n "$repos_mountpoint" ] && [ -d "$repos_mountpoint" ]; then
93 tar -czf "$repos_out" -C "$(dirname "$repos_mountpoint")" "$(basename "$repos_mountpoint")"
94 repos_size="$(du -h "$repos_out" | cut -f1)"
95 repos_file_count="$(tar -tzf "$repos_out" | wc -l)"
96 if [ "$repos_file_count" -lt 5 ]; then
97 echo "$(date -Is) FATAL: repos tarball looks empty ($repos_file_count entries) — refusing to keep it" >&2
98 rm -f "$repos_out"
99 exit 1
100 fi
101 echo "$(date -Is) repos snapshot: $repos_out ($repos_size, $repos_file_count entries)"
102else
103 echo "$(date -Is) FATAL: could not resolve docker volume '$GIT_REPOS_VOLUME' — repos NOT backed up this run" >&2
104 exit 1
105fi
106
41d6ea3ccanty labs107# Retention — keep RETAIN_DAYS of daily dumps.
108find "$BACKUP_DIR" -name 'gluecron-db-*.dump' -mtime +$RETAIN_DAYS -delete
5dc0e94ccanty labs109find "$BACKUP_DIR" -name 'gluecron-repos-*.tar.gz' -mtime +$RETAIN_DAYS -delete
41d6ea3ccanty labs110
111# Optional self-owned offsite copy over Tailscale (preferred — no third party).
112if [ -n "${BACKUP_SCP_TARGET:-}" ]; then
113 scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$out" "$BACKUP_SCP_TARGET/" \
5dc0e94ccanty labs114 && echo "$(date -Is) offsite scp ok -> $BACKUP_SCP_TARGET (db)" \
115 || echo "$(date -Is) WARN: offsite scp failed (db)"
116 if [ -f "$repos_out" ]; then
117 scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$repos_out" "$BACKUP_SCP_TARGET/" \
118 && echo "$(date -Is) offsite scp ok -> $BACKUP_SCP_TARGET (repos)" \
119 || echo "$(date -Is) WARN: offsite scp failed (repos)"
120 fi
41d6ea3ccanty labs121fi
f635e2fClaude122
41d6ea3ccanty labs123# Optional rclone offsite copy (if you do use a bucket).
f635e2fClaude124if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null 2>&1; then
5dc0e94ccanty labs125 rclone copy "$out" "$BACKUP_RCLONE_REMOTE" || echo "$(date -Is) WARN: rclone copy failed (db)"
126 if [ -f "$repos_out" ]; then
127 rclone copy "$repos_out" "$BACKUP_RCLONE_REMOTE" || echo "$(date -Is) WARN: rclone copy failed (repos)"
128 fi
f635e2fClaude129fi
130
41d6ea3ccanty labs131# Optional dead-man's-switch heartbeat (alerts if a backup is ever missed).
f635e2fClaude132if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then
133 curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true
134fi
135
5dc0e94ccanty labs136echo "$(date -Is) backup written: $out ($size, $obj_count objects); $repos_out ($repos_size, $repos_file_count entries)"