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.
| f635e2f | 1 | #!/usr/bin/env bash |
| 2 | # | |
| 5dc0e94 | 3 | # Daily production backup for the standalone/Coolify box. |
| 41d6ea3 | 4 | # Installed as a systemd timer by scripts/standalone-deploy.sh. |
| 5 | # | |
| 5dc0e94 | 6 | # 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. | |
| 41d6ea3 | 17 | # |
| 5dc0e94 | 18 | # HOW (DB): Neon runs Postgres 17, so we dump with an ephemeral `postgres:17` |
| 41d6ea3 | 19 | # 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 | # | |
| 5dc0e94 | 25 | # 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. | |
| f635e2f | 31 | # |
| 32 | # Optional env (set in /opt/gluecron/.env): | |
| 33 | # BACKUP_RCLONE_REMOTE e.g. r2:gluecron-backups (needs rclone configured) | |
| 41d6ea3 | 34 | # 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) | |
| 5dc0e94 | 36 | # GIT_REPOS_VOLUME docker volume name for bare repos (default: gluecron_git-repos) |
| f635e2f | 37 | set -euo pipefail |
| 38 | ||
| 39 | REPO_DIR="/opt/gluecron" | |
| 40 | BACKUP_DIR="$REPO_DIR/backups" | |
| 41 | RETAIN_DAYS=14 | |
| 41d6ea3 | 42 | APP_CONTAINER="gluecron-gluecron-1" |
| 43 | PG_IMAGE="postgres:17-alpine" | |
| 5dc0e94 | 44 | GIT_REPOS_VOLUME="${GIT_REPOS_VOLUME:-gluecron_git-repos}" |
| f635e2f | 45 | |
| 46 | cd "$REPO_DIR" | |
| 47 | mkdir -p "$BACKUP_DIR" | |
| 48 | ts=$(date +%Y%m%d-%H%M%S) | |
| 41d6ea3 | 49 | out="$BACKUP_DIR/gluecron-db-$ts.dump" |
| 5dc0e94 | 50 | repos_out="$BACKUP_DIR/gluecron-repos-$ts.tar.gz" |
| 41d6ea3 | 51 | |
| 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. | |
| 55 | raw_url="$(docker exec "$APP_CONTAINER" printenv DATABASE_URL)" | |
| 56 | clean_url="${raw_url%%DATABASE_URL=*}" | |
| 57 | if [ -z "$clean_url" ]; then | |
| 58 | echo "$(date -Is) FATAL: could not resolve DATABASE_URL from $APP_CONTAINER" >&2 | |
| 59 | exit 1 | |
| 60 | fi | |
| f635e2f | 61 | |
| 41d6ea3 | 62 | # 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`). | |
| 66 | docker 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 | } | |
| 75 | rm -f /tmp/backup-err.$$ | |
| f635e2f | 76 | |
| 41d6ea3 | 77 | # Sanity: a valid custom-format dump must list objects. Guards against a |
| 78 | # 0-byte/corrupt file being counted as a good backup. | |
| 79 | obj_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)" | |
| 81 | if [ "${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 | |
| 85 | fi | |
| 86 | ||
| 87 | size="$(du -h "$out" | cut -f1)" | |
| 88 | ||
| 5dc0e94 | 89 | # 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. | |
| 91 | repos_mountpoint="$(docker volume inspect "$GIT_REPOS_VOLUME" --format '{{ .Mountpoint }}' 2>/dev/null || true)" | |
| 92 | if [ -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)" | |
| 102 | else | |
| 103 | echo "$(date -Is) FATAL: could not resolve docker volume '$GIT_REPOS_VOLUME' — repos NOT backed up this run" >&2 | |
| 104 | exit 1 | |
| 105 | fi | |
| 106 | ||
| 41d6ea3 | 107 | # Retention — keep RETAIN_DAYS of daily dumps. |
| 108 | find "$BACKUP_DIR" -name 'gluecron-db-*.dump' -mtime +$RETAIN_DAYS -delete | |
| 5dc0e94 | 109 | find "$BACKUP_DIR" -name 'gluecron-repos-*.tar.gz' -mtime +$RETAIN_DAYS -delete |
| 41d6ea3 | 110 | |
| 111 | # Optional self-owned offsite copy over Tailscale (preferred — no third party). | |
| 112 | if [ -n "${BACKUP_SCP_TARGET:-}" ]; then | |
| 113 | scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$out" "$BACKUP_SCP_TARGET/" \ | |
| 5dc0e94 | 114 | && 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 | |
| 41d6ea3 | 121 | fi |
| f635e2f | 122 | |
| 41d6ea3 | 123 | # Optional rclone offsite copy (if you do use a bucket). |
| f635e2f | 124 | if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null 2>&1; then |
| 5dc0e94 | 125 | 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 | |
| f635e2f | 129 | fi |
| 130 | ||
| 41d6ea3 | 131 | # Optional dead-man's-switch heartbeat (alerts if a backup is ever missed). |
| f635e2f | 132 | if [ -n "${HEALTHCHECK_PING_URL:-}" ]; then |
| 133 | curl -fsS -m 10 "$HEALTHCHECK_PING_URL" >/dev/null 2>&1 || true | |
| 134 | fi | |
| 135 | ||
| 5dc0e94 | 136 | echo "$(date -Is) backup written: $out ($size, $obj_count objects); $repos_out ($repos_size, $repos_file_count entries)" |