#!/usr/bin/env bash # # Free Linux server backup: files + MySQL/PostgreSQL dumps, encrypted, # uploaded off-site, with MonitorSpider backup monitoring built in. # # - dumps MySQL (--all-databases) and/or PostgreSQL (pg_dumpall), if enabled # - tars the dumps + the folders you list, gzip-compressed # - optionally encrypts with GPG symmetric AES-256 (set ENCRYPT_PASSPHRASE) # - uploads via rclone (any cloud/SFTP/FTP remote) or curl (ftp:// / sftp://) # - applies retention locally and (rclone only) remotely # - pings your MonitorSpider backup monitor with success/fail + bytes + # duration — you get alerted when a backup fails AND when it silently # stops running (no ping = "didn't run" alert) # # Setup: # 1. Copy this script somewhere root-owned, chmod 700. # 2. Fill in the CONFIG block (or put overrides in /etc/monitorspider-backup.conf). # 3. Create a "Backup (push)" monitor at https://monitorspider.com/dashboard/ # and paste its ping URL into PING_URL. # 4. Test run it, then cron it: 17 3 * * 0 /usr/local/sbin/linux-files-mysql.sh # # Deps: bash, tar, gzip, curl; gpg if encrypting; rclone if RCLONE_REMOTE set; # mysqldump / pg_dumpall for the database options. # From https://monitorspider.com/backup-kits/ — MIT licensed. set -euo pipefail # ---------------- CONFIG ---------------- PING_URL="" # https://monitorspider.com/srv/backup-ping?id=...&token=... SET_PREFIX="$(hostname -s)" # backup set name prefix BACKUP_DIRS=(/etc /var/www) # folders to back up STAGING_DIR="/var/backups/monitorspider" MYSQL_DUMP="no" # yes = mysqldump --all-databases (uses ~/.my.cnf or MYSQL_DEFAULTS_FILE) MYSQL_DEFAULTS_FILE="" # e.g. /root/.my.cnf with [client] user/password PGSQL_DUMP="no" # yes = pg_dumpall as the postgres user (run this script as root) ENCRYPT_PASSPHRASE="" # non-empty = gpg AES-256 symmetric encryption; KEEP A COPY OFF THIS SERVER RCLONE_REMOTE="" # e.g. "offsite:backups/web1" — takes precedence over CURL_UPLOAD_URL CURL_UPLOAD_URL="" # e.g. "ftp://user:pass@backups.example.com/web1/" (trailing slash!) KEEP_LOCAL=2 # local archives to keep REMOTE_RETENTION_DAYS=28 # rclone remotes only: delete archives older than this # ----------------------------------------- [ -f /etc/monitorspider-backup.conf ] && . /etc/monitorspider-backup.conf STAMP="$(date +%Y-%m-%d-%H%M)" SET_NAME="${SET_PREFIX}-${STAMP}-weekly" WORK_DIR="${STAGING_DIR}/work" ARCHIVE_DIR="${STAGING_DIR}/archives" LOG_FILE="${STAGING_DIR}/logs/${SET_NAME}.log" mkdir -p "$WORK_DIR" "$ARCHIVE_DIR" "$(dirname "$LOG_FILE")" exec > >(tee -a "$LOG_FILE") 2>&1 START_TS=$(date +%s) ARCHIVE_BYTES=-1 FAIL_MSG="" log() { echo "[$(date +%H:%M:%S)] $*"; } ping_monitorspider() { # $1 status, $2 message [ -n "$PING_URL" ] || return 0 local body body=$(printf '{"status":"%s","set":"%s","message":"%s"' "$1" "$SET_NAME" "${2//\"/}") [ "$ARCHIVE_BYTES" -ge 0 ] && body+=",\"bytes\":$ARCHIVE_BYTES" body+=",\"duration_s\":$(( $(date +%s) - START_TS ))}" # The watchdog must never break the backup: failures are logged, not fatal. curl -fsS -X POST -H 'Content-Type: application/json' -d "$body" --max-time 30 "$PING_URL" \ >/dev/null || log "MonitorSpider ping failed (status $1)" } on_exit() { local rc=$? if [ $rc -eq 0 ] && [ -z "$FAIL_MSG" ]; then log "Backup completed successfully." ping_monitorspider success "uploaded $(numfmt --to=iec ${ARCHIVE_BYTES}B 2>/dev/null || echo "${ARCHIVE_BYTES} bytes")" else [ -n "$FAIL_MSG" ] || FAIL_MSG="failed with exit code $rc (see $LOG_FILE)" log "Backup FAILED: $FAIL_MSG" ping_monitorspider fail "$FAIL_MSG" fi } trap on_exit EXIT fail() { FAIL_MSG="$1"; log "ERROR: $1"; exit 1; } log "Starting backup set $SET_NAME" ping_monitorspider start "" rm -rf "${WORK_DIR:?}"/* && mkdir -p "$WORK_DIR/db" # ---- database dumps ---- if [ "$MYSQL_DUMP" = "yes" ]; then log "Dumping MySQL (all databases)" MYSQL_ARGS=(--all-databases --single-transaction --routines --events) [ -n "$MYSQL_DEFAULTS_FILE" ] && MYSQL_ARGS=(--defaults-extra-file="$MYSQL_DEFAULTS_FILE" "${MYSQL_ARGS[@]}") mysqldump "${MYSQL_ARGS[@]}" | gzip > "$WORK_DIR/db/mysql-all.sql.gz" \ || fail "mysqldump failed" [ -s "$WORK_DIR/db/mysql-all.sql.gz" ] || fail "mysqldump produced an empty file" fi if [ "$PGSQL_DUMP" = "yes" ]; then log "Dumping PostgreSQL (pg_dumpall)" su -s /bin/sh postgres -c pg_dumpall | gzip > "$WORK_DIR/db/pgsql-all.sql.gz" \ || fail "pg_dumpall failed" [ -s "$WORK_DIR/db/pgsql-all.sql.gz" ] || fail "pg_dumpall produced an empty file" fi # ---- archive ---- ARCHIVE="$ARCHIVE_DIR/${SET_NAME}.tar.gz" EXISTING_DIRS=() for d in "${BACKUP_DIRS[@]}"; do [ -e "$d" ] && EXISTING_DIRS+=("$d") || log "WARNING: configured dir missing: $d" done [ ${#EXISTING_DIRS[@]} -gt 0 ] || [ "$MYSQL_DUMP" = "yes" ] || [ "$PGSQL_DUMP" = "yes" ] \ || fail "nothing to back up: set BACKUP_DIRS and/or enable a database dump" log "Archiving ${EXISTING_DIRS[*]:-'(db dumps only)'}" tar -czf "$ARCHIVE" -C "$WORK_DIR" db $(for d in "${EXISTING_DIRS[@]}"; do echo "-C / ${d#/}"; done) \ || fail "tar failed" if [ -n "$ENCRYPT_PASSPHRASE" ]; then log "Encrypting (gpg AES-256)" gpg --batch --yes --symmetric --cipher-algo AES256 \ --passphrase "$ENCRYPT_PASSPHRASE" -o "${ARCHIVE}.gpg" "$ARCHIVE" \ || fail "gpg encryption failed" rm -f "$ARCHIVE" ARCHIVE="${ARCHIVE}.gpg" fi sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" ARCHIVE_BYTES=$(stat -c%s "$ARCHIVE") log "Archive: $ARCHIVE ($(numfmt --to=iec ${ARCHIVE_BYTES}B 2>/dev/null || echo "$ARCHIVE_BYTES bytes"))" # ---- upload ---- if [ -n "$RCLONE_REMOTE" ]; then log "Uploading via rclone to $RCLONE_REMOTE" rclone copy "$ARCHIVE" "$RCLONE_REMOTE/" --contimeout 30s || fail "rclone upload failed" rclone copy "${ARCHIVE}.sha256" "$RCLONE_REMOTE/" || fail "rclone manifest upload failed" log "Remote retention: deleting sets older than ${REMOTE_RETENTION_DAYS}d" rclone delete "$RCLONE_REMOTE/" --min-age "${REMOTE_RETENTION_DAYS}d" \ --include "${SET_PREFIX}-*" || log "WARNING: remote retention failed (upload was fine)" elif [ -n "$CURL_UPLOAD_URL" ]; then log "Uploading via curl to ${CURL_UPLOAD_URL%%:*}://..." curl -fsS --max-time 7200 -T "$ARCHIVE" "$CURL_UPLOAD_URL" || fail "curl upload failed" curl -fsS -T "${ARCHIVE}.sha256" "$CURL_UPLOAD_URL" || fail "curl manifest upload failed" log "NOTE: curl uploads have no remote retention — prune old sets on the server, or use rclone" else fail "no upload configured: set RCLONE_REMOTE or CURL_UPLOAD_URL" fi # ---- local retention ---- ls -1t "$ARCHIVE_DIR/${SET_PREFIX}-"*.tar.gz* 2>/dev/null | grep -v '\.sha256$' \ | tail -n +$((KEEP_LOCAL + 1)) | while read -r old; do log "Retention: deleting local $old" rm -f "$old" "$old.sha256" "${old%.gpg}.sha256" done find "$(dirname "$LOG_FILE")" -name '*.log' -mtime +180 -delete