Skip to main content

max / makenotwork

v0.9.7: parameterized backups for makenotwork + multithreaded, offsite to astra - backup-db.sh takes a DB name (default makenotwork), writes to per-DB subdir /var/lib/mnw/backups/<db>/, prunes per-DB only - sync-backup-offsite.sh takes the timestamped file from backup-db.sh and rsyncs to /opt/backups/mnw/<db>/ on astra, maintains a per-DB latest.sql.gz hard link there - Drops the makenotwork-specific hardcoding; multithreaded backups now run under the multithreaded cron alongside makenotwork Tailscale SSH carries the auth via the tag:prod -> max@tag:testing rule just added to the tailnet ACL — no authorized_keys management needed.
Author: Max Johnson <me@maxj.phd> · 2026-06-03 22:32 UTC
Commit: 728480562fa5ee76e994d97c30f0b070e9214f33
Parent: d972fe5
4 files changed, +72 insertions, -62 deletions
@@ -4140,7 +4140,7 @@ dependencies = [
4140 4140
4141 4141 [[package]]
4142 4142 name = "makenotwork"
4143 - version = "0.9.6"
4143 + version = "0.9.7"
4144 4144 dependencies = [
4145 4145 "anyhow",
4146 4146 "apple-codesign",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.9.6"
3 + version = "0.9.7"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -2,42 +2,38 @@
2 2 # Makenotwork Database Backup Script
3 3 # Runs daily via cron, keeps 30 days of backups.
4 4 #
5 - # Setup:
6 - # 1. Copy to server:
7 - # scp deploy/backup-db.sh root@<server>:/opt/makenotwork/
8 - # chmod +x /opt/makenotwork/backup-db.sh
5 + # Usage: backup-db.sh [db_name]
6 + # db_name defaults to "makenotwork". Pass "multithreaded" (or any other
7 + # DB) to back up that one instead. The script runs as the corresponding
8 + # OS user (peer auth), so call from that user's crontab.
9 9 #
10 - # 2. Create backup directory:
11 - # mkdir -p /opt/makenotwork/backups
12 - # chown makenotwork:makenotwork /opt/makenotwork/backups
13 - #
14 - # 3. Add cron job (as makenotwork user):
15 - # sudo crontab -u makenotwork -e
10 + # Setup (per DB user):
11 + # 1. Deploy script to /opt/mnw/backup-db.sh (chmod 755)
12 + # 2. Ensure /var/lib/mnw/backups exists and is writable by the DB user
13 + # 3. Add cron job as that user:
14 + # sudo crontab -u <db_user> -e
16 15 # # Daily at 03:00 UTC:
17 - # 0 3 * * * /opt/makenotwork/backup-db.sh >> /opt/makenotwork/backups/backup.log 2>&1
16 + # 0 3 * * * /opt/mnw/backup-db.sh <db_name> >> /var/lib/mnw/backups/<db_name>/backup.log 2>&1
18 17
19 18 set -euo pipefail
20 19
21 - # Configuration
22 - BACKUP_DIR="/opt/makenotwork/backups"
23 - DB_NAME="makenotwork"
24 - DB_USER="makenotwork"
20 + DB_NAME="${1:-makenotwork}"
21 + DB_USER="$DB_NAME" # peer auth: OS user matches DB user
22 + BACKUP_DIR="/var/lib/mnw/backups/${DB_NAME}"
25 23 RETENTION_DAYS=30
26 24
27 - # Derived
28 25 TIMESTAMP=$(date +%Y%m%d-%H%M%S)
29 26 BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}-${TIMESTAMP}.sql.gz"
30 27
31 - echo "[$(date -Iseconds)] Starting backup..."
28 + echo "[$(date -Iseconds)] Starting backup of ${DB_NAME}..."
32 29
33 - # Ensure backup directory exists
34 30 mkdir -p "$BACKUP_DIR"
31 + # Move into the backup dir so later `find ... -delete` doesn't fail when invoked
32 + # from a CWD the DB user can't traverse (e.g. /root under `sudo -u`).
33 + cd "$BACKUP_DIR"
35 34
36 - # Dump and compress
37 - # Uses peer auth (no password needed when running as makenotwork user)
38 35 pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_FILE"
39 36
40 - # Verify the file is non-empty
41 37 FILESIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || stat -f%z "$BACKUP_FILE" 2>/dev/null)
42 38 if [ "$FILESIZE" -lt 100 ]; then
43 39 echo "[$(date -Iseconds)] ERROR: Backup file suspiciously small (${FILESIZE} bytes)"
@@ -46,24 +42,24 @@ fi
46 42
47 43 echo "[$(date -Iseconds)] Backup complete: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
48 44
49 - # Prune backups older than retention period
45 + # Stable per-DB latest filename for downstream pullers (sando's backup-puller
46 + # user rsyncs `latest-<db>.sql.gz`). Hard link rather than symlink because rrsync
47 + # blocks `-L`; atomic via temp-then-rename so the link is never briefly missing.
48 + LATEST="${BACKUP_DIR}/latest.sql.gz"
49 + ln -f "$BACKUP_FILE" "${LATEST}.new"
50 + mv -Tf "${LATEST}.new" "$LATEST"
51 +
50 52 DELETED=$(find "$BACKUP_DIR" -name "${DB_NAME}-*.sql.gz" -mtime +${RETENTION_DAYS} -delete -print | wc -l)
51 53 if [ "$DELETED" -gt 0 ]; then
52 - echo "[$(date -Iseconds)] Pruned $DELETED backup(s) older than ${RETENTION_DAYS} days"
54 + echo "[$(date -Iseconds)] Pruned $DELETED ${DB_NAME} backup(s) older than ${RETENTION_DAYS} days"
53 55 fi
54 56
55 - # Summary
56 57 TOTAL=$(find "$BACKUP_DIR" -name "${DB_NAME}-*.sql.gz" | wc -l)
57 - echo "[$(date -Iseconds)] Total backups on disk: $TOTAL"
58 + echo "[$(date -Iseconds)] Total ${DB_NAME} backups on disk: $TOTAL"
58 59
59 - # Sync to offsite host (best-effort — failure here does not fail the backup)
60 + # Sync to offsite host (best-effort — failure here does not fail the backup).
60 61 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
61 62 OFFSITE_SCRIPT="${SCRIPT_DIR}/sync-backup-offsite.sh"
62 63 if [ -x "$OFFSITE_SCRIPT" ]; then
63 - "$OFFSITE_SCRIPT"
64 - else
65 - # Fallback: check deployed location
66 - if [ -x /opt/makenotwork/sync-backup-offsite.sh ]; then
67 - /opt/makenotwork/sync-backup-offsite.sh
68 - fi
64 + "$OFFSITE_SCRIPT" "$DB_NAME" "$BACKUP_FILE" || true
69 65 fi
@@ -2,26 +2,31 @@
2 2 # Sync database backups to offsite host (astra) via Tailscale.
3 3 # Called by backup-db.sh after each successful backup.
4 4 #
5 - # Setup on astra:
5 + # Usage: sync-backup-offsite.sh <db_name> <backup_file>
6 + # db_name — database name (used for subdir and prune glob)
7 + # backup_file — absolute path to the timestamped .sql.gz produced by this run
8 + #
9 + # On astra, backups land in /opt/backups/mnw/<db_name>/, with a per-DB
10 + # latest.sql.gz hard link maintained for downstream pullers (sando).
11 + #
12 + # Setup on astra (one-time):
6 13 # mkdir -p /opt/backups/mnw
14 + # chown max:max /opt/backups/mnw
7 15 #
8 - # Setup on Hetzner (as makenotwork user):
9 - # Ensure SSH key-based auth to astra is configured:
10 - # ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
11 - # ssh-copy-id max@100.106.221.39
12 - # Test: ssh max@100.106.221.39 "echo ok"
16 + # Setup on Hetzner: tailnet ACL grants tag:prod -> max@tag:testing SSH (no
17 + # pubkey wrangling — Tailscale SSH bypasses authorized_keys via tailnet cert).
13 18
14 19 set -euo pipefail
15 20
21 + DB_NAME="${1:?usage: sync-backup-offsite.sh <db_name> <backup_file>}"
22 + BACKUP_FILE="${2:?usage: sync-backup-offsite.sh <db_name> <backup_file>}"
23 +
16 24 OFFSITE_HOST="100.106.221.39" # astra (Tailscale IP)
17 25 OFFSITE_USER="max"
18 - OFFSITE_DIR="/opt/backups/mnw"
19 - BACKUP_DIR="/opt/makenotwork/backups"
20 - DB_NAME="makenotwork"
26 + OFFSITE_DIR="/opt/backups/mnw/${DB_NAME}"
21 27 OFFSITE_RETENTION_DAYS=30
22 28 WAM_URL="${WAM_URL:-http://127.0.0.1:7890}"
23 29
24 - # Open a WAM ticket for offsite backup failures.
25 30 wam_alert() {
26 31 local title="$1"
27 32 local body="${2:-}"
@@ -31,37 +36,46 @@ wam_alert() {
31 36 >/dev/null 2>&1 || true
32 37 }
33 38
34 - # Find the most recent backup
35 - LATEST=$(ls -t "${BACKUP_DIR}/${DB_NAME}"-*.sql.gz 2>/dev/null | head -1)
36 - if [ -z "$LATEST" ]; then
37 - echo "[$(date -Iseconds)] OFFSITE: No backups found to sync"
39 + if [ ! -f "$BACKUP_FILE" ]; then
40 + echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): backup file missing: $BACKUP_FILE"
38 41 exit 0
39 42 fi
40 43
41 - echo "[$(date -Iseconds)] OFFSITE: Syncing $(basename "$LATEST") to ${OFFSITE_HOST}:${OFFSITE_DIR}"
44 + BASENAME=$(basename "$BACKUP_FILE")
45 + SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
42 46
43 - # Transfer with compression (already gzipped, so -z won't help much, but
44 - # rsync handles partial transfers and resume on failure)
45 - if rsync -e "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" \
46 - --timeout=120 \
47 - "$LATEST" \
47 + echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): Syncing ${BASENAME} to ${OFFSITE_HOST}:${OFFSITE_DIR}"
48 +
49 + # Ensure the per-DB offsite dir exists.
50 + ssh ${SSH_OPTS} "${OFFSITE_USER}@${OFFSITE_HOST}" "mkdir -p '${OFFSITE_DIR}'"
51 +
52 + if rsync -e "ssh ${SSH_OPTS}" --timeout=120 \
53 + "$BACKUP_FILE" \
48 54 "${OFFSITE_USER}@${OFFSITE_HOST}:${OFFSITE_DIR}/"; then
49 - echo "[$(date -Iseconds)] OFFSITE: Transfer complete"
55 + echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): Transfer complete"
50 56 else
51 - echo "[$(date -Iseconds)] OFFSITE: Transfer FAILED (astra unreachable or SSH error)"
52 - wam_alert "Offsite backup sync failed" "rsync to ${OFFSITE_HOST}:${OFFSITE_DIR} failed for $(basename "$LATEST"). Check Tailscale connectivity and SSH auth."
57 + echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): Transfer FAILED (astra unreachable or SSH error)"
58 + wam_alert "Offsite backup sync failed (${DB_NAME})" "rsync to ${OFFSITE_HOST}:${OFFSITE_DIR} failed for ${BASENAME}. Check Tailscale connectivity and SSH auth."
53 59 exit 0
54 60 fi
55 61
56 - # Prune old offsite backups
57 - DELETED=$(ssh -o ConnectTimeout=10 "${OFFSITE_USER}@${OFFSITE_HOST}" \
62 + # Refresh the per-DB latest.sql.gz hard link on astra (atomic temp-then-rename).
63 + ssh ${SSH_OPTS} "${OFFSITE_USER}@${OFFSITE_HOST}" "
64 + set -e
65 + cd '${OFFSITE_DIR}'
66 + ln -f '${BASENAME}' latest.sql.gz.new
67 + mv -Tf latest.sql.gz.new latest.sql.gz
68 + " || echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): WARNING — failed to refresh latest.sql.gz"
69 +
70 + # Prune offsite backups older than retention.
71 + DELETED=$(ssh ${SSH_OPTS} "${OFFSITE_USER}@${OFFSITE_HOST}" \
58 72 "find ${OFFSITE_DIR} -name '${DB_NAME}-*.sql.gz' -mtime +${OFFSITE_RETENTION_DAYS} -delete -print 2>/dev/null | wc -l" \
59 73 2>/dev/null || echo "0")
60 74 if [ "$DELETED" -gt 0 ]; then
61 - echo "[$(date -Iseconds)] OFFSITE: Pruned ${DELETED} backup(s) older than ${OFFSITE_RETENTION_DAYS} days"
75 + echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): Pruned ${DELETED} backup(s) older than ${OFFSITE_RETENTION_DAYS} days"
62 76 fi
63 77
64 - TOTAL=$(ssh -o ConnectTimeout=10 "${OFFSITE_USER}@${OFFSITE_HOST}" \
78 + TOTAL=$(ssh ${SSH_OPTS} "${OFFSITE_USER}@${OFFSITE_HOST}" \
65 79 "ls ${OFFSITE_DIR}/${DB_NAME}-*.sql.gz 2>/dev/null | wc -l" \
66 80 2>/dev/null || echo "?")
67 - echo "[$(date -Iseconds)] OFFSITE: Total backups on astra: ${TOTAL}"
81 + echo "[$(date -Iseconds)] OFFSITE(${DB_NAME}): Total backups on astra: ${TOTAL}"