Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.shBlame96 lines · 2 contributors
f635e2fClaude1#!/usr/bin/env bash
2#
41d6ea3ccanty labs3# Daily production-database backup for the standalone/Coolify box.
4# Installed as a systemd timer by scripts/standalone-deploy.sh.
5#
6# WHAT IT BACKS UP (corrected 2026-07-14): the REAL production database, which
7# is **Neon** — reached via the app container's own DATABASE_URL. The compose
8# also runs an unused local `postgres` container; the previous version of this
9# script dumped THAT by mistake, so daily backups were capturing empty/stale
10# data. Do not reintroduce `$COMPOSE exec postgres pg_dump`.
11#
12# HOW: Neon runs Postgres 17, so we dump with an ephemeral `postgres:17`
13# container (the box's local client is 16 and refuses a newer server). The
14# app's DATABASE_URL on this box is malformed (two connstrings mashed on one
15# line — a fragile latent bug), so we take the clean Neon URL up to the second
16# "DATABASE_URL=" marker. `${URL%%DATABASE_URL=*}` is a no-op once the .env is
17# fixed, so this stays correct either way.
18#
19# Output: custom-format (`-Fc`) dumps — compressed + restorable selectively via
20# pg_restore. Compatible with scripts/restore.sh and scripts/backup-restore-drill.sh.
f635e2fClaude21#
22# Optional env (set in /opt/gluecron/.env):
23# BACKUP_RCLONE_REMOTE e.g. r2:gluecron-backups (needs rclone configured)
41d6ea3ccanty labs24# BACKUP_SCP_TARGET e.g. user@100.x.y.z:/backups (self-owned offsite over Tailscale)
25# HEALTHCHECK_PING_URL e.g. https://hc-ping.com/<uuid> (dead-man's-switch)
f635e2fClaude26set -euo pipefail
27
28REPO_DIR="/opt/gluecron"
29BACKUP_DIR="$REPO_DIR/backups"
30RETAIN_DAYS=14
41d6ea3ccanty labs31APP_CONTAINER="gluecron-gluecron-1"
32PG_IMAGE="postgres:17-alpine"
f635e2fClaude33
34cd "$REPO_DIR"
35mkdir -p "$BACKUP_DIR"
36ts=$(date +%Y%m%d-%H%M%S)
41d6ea3ccanty labs37out="$BACKUP_DIR/gluecron-db-$ts.dump"
38
39# Resolve the live DATABASE_URL from the running app, then clean it. Reading it
40# from the container (not .env) means the backup always matches what the app
41# actually connects to.
42raw_url="$(docker exec "$APP_CONTAINER" printenv DATABASE_URL)"
43clean_url="${raw_url%%DATABASE_URL=*}"
44if [ -z "$clean_url" ]; then
45 echo "$(date -Is) FATAL: could not resolve DATABASE_URL from $APP_CONTAINER" >&2
46 exit 1
47fi
f635e2fClaude48
41d6ea3ccanty labs49# Dump Neon with a matching-major-version client. Custom format, no owner/ACLs
50# (portable across restore targets). Password + URL travel only in container
51# env; the pg_dump runs via `sh -c` so $NEONURL expands INSIDE the container
52# (host-side expansion would be empty and trip `set -u`).
53docker run --rm -e NEONURL="$clean_url" -e OUTFILE="/backups/$(basename "$out")" \
54 -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \
55 sh -c 'pg_dump "$NEONURL" --format=custom --no-owner --no-privileges -f "$OUTFILE"' \
56 2>/tmp/backup-err.$$ || {
57 echo "$(date -Is) FATAL: pg_dump failed:" >&2
58 cat /tmp/backup-err.$$ >&2
59 rm -f /tmp/backup-err.$$ "$out"
60 exit 1
61 }
62rm -f /tmp/backup-err.$$
f635e2fClaude63
41d6ea3ccanty labs64# Sanity: a valid custom-format dump must list objects. Guards against a
65# 0-byte/corrupt file being counted as a good backup.
66obj_count="$(docker run --rm -e F="/backups/$(basename "$out")" -v "$BACKUP_DIR:/backups" "$PG_IMAGE" \
67 sh -c 'pg_restore --list "$F"' 2>/dev/null | grep -cE 'TABLE DATA|TABLE|SEQUENCE' || true)"
68if [ "${obj_count:-0}" -lt 20 ]; then
69 echo "$(date -Is) FATAL: dump looks empty ($obj_count objects) — refusing to keep it" >&2
70 rm -f "$out"
71 exit 1
72fi
73
74size="$(du -h "$out" | cut -f1)"
75
76# Retention — keep RETAIN_DAYS of daily dumps.
77find "$BACKUP_DIR" -name 'gluecron-db-*.dump' -mtime +$RETAIN_DAYS -delete
78
79# Optional self-owned offsite copy over Tailscale (preferred — no third party).
80if [ -n "${BACKUP_SCP_TARGET:-}" ]; then
81 scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$out" "$BACKUP_SCP_TARGET/" \
82 && echo "$(date -Is) offsite scp ok -> $BACKUP_SCP_TARGET" \
83 || echo "$(date -Is) WARN: offsite scp failed"
84fi
f635e2fClaude85
41d6ea3ccanty labs86# Optional rclone offsite copy (if you do use a bucket).
f635e2fClaude87if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null 2>&1; then
41d6ea3ccanty labs88 rclone copy "$out" "$BACKUP_RCLONE_REMOTE" || echo "$(date -Is) WARN: rclone copy failed"
f635e2fClaude89fi
90
41d6ea3ccanty labs91# Optional dead-man's-switch heartbeat (alerts if a backup is ever missed).
f635e2fClaude92if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then
93 curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true
94fi
95
41d6ea3ccanty labs96echo "$(date -Is) backup written: $out ($size, $obj_count objects)"