#!/usr/bin/env bash # Nightly backup: a dated dump of the database, and a mirror of the model files. # # Run it from anywhere — it finds the folder holding docker-compose.yml itself. # Printhive keeps running throughout; pg_dump takes a consistent snapshot. # # ./tools/backup.sh # # Settings (in .env beside docker-compose.yml, or the environment): # BACKUP_DIR where backups go (default: ./backups) # DATA_DIR where the data lives (default: ./data) # KEEP_DAYS dumps to keep, in days (default: 14) set -euo pipefail cd "$(dirname "$0")/.." # A setting from the environment, else from .env, else the fallback. Tolerates # "export NAME=", quotes, spaces and Windows line endings. setting() { # name, fallback local from_env="${!1:-}" if [ -n "$from_env" ]; then echo "$from_env"; return; fi if [ -f .env ]; then local line line="$(sed -e 's/\r$//' .env \ | grep -E "^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=" \ | tail -1 | cut -d= -f2- \ | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/")" if [ -n "$line" ]; then echo "$line"; return; fi fi echo "$2" } BACKUP_DIR="$(setting BACKUP_DIR ./backups)" DATA_DIR="$(setting DATA_DIR ./data)" KEEP_DAYS="$(setting KEEP_DAYS 14)" stamp="$(date +%Y-%m-%d)" echo "$(date '+%F %T') data: $DATA_DIR → backups: $BACKUP_DIR" # Checked before anything is written: on an external disk that isn't mounted, # the folder is simply missing, and a backup of nothing is worse than none. if [ ! -d "$DATA_DIR/uploads" ]; then echo "$(date '+%F %T') FAILED: no $DATA_DIR/uploads — is the disk mounted, and is DATA_DIR set in .env?" >&2 exit 1 fi mkdir -p "$BACKUP_DIR/db" # --- the database ----------------------------------------------------------- # Written to a .part file first, so an interrupted run never leaves behind # something that looks like a good backup. dump="$BACKUP_DIR/db/printhive-$stamp.dump" # Dumped inside the container and read back there — a dump that can't be listed # is no backup at all, and pg_restore can only list a real file, not a pipe. if ! docker compose exec -T db sh -c \ 'pg_dump -U printhive -Fc printhive > /tmp/printhive.dump && pg_restore -l /tmp/printhive.dump > /dev/null'; then echo "$(date '+%F %T') FAILED: couldn’t dump the database — keeping the old backups" >&2 docker compose exec -T db rm -f /tmp/printhive.dump || true exit 1 fi docker compose exec -T db cat /tmp/printhive.dump > "$dump.part" docker compose exec -T db rm -f /tmp/printhive.dump || true if [ ! -s "$dump.part" ]; then echo "$(date '+%F %T') FAILED: the dump came out empty — keeping the old backups" >&2 rm -f "$dump.part" exit 1 fi mv "$dump.part" "$dump" echo "$(date '+%F %T') database → $dump ($(du -h "$dump" | cut -f1))" # Older dumps go, so the folder doesn't grow for ever. find "$BACKUP_DIR/db" -name 'printhive-*.dump' -type f -mtime "+$KEEP_DAYS" -delete # --- the model files -------------------------------------------------------- # A mirror, not a history: it matches what's there now, deletions included. # Thumbnails, previews and extracted archives are left out — they rebuild. rsync -a --delete "$DATA_DIR/uploads/" "$BACKUP_DIR/uploads/" echo "$(date '+%F %T') files → $BACKUP_DIR/uploads ($(du -sh "$BACKUP_DIR/uploads" | cut -f1))"