|
| 1 | +# Automated rsync Backup Script |
| 2 | + |
| 3 | +This script is for automating backups of a local directory to a remote server (like a Hetzner Storage Box) using `rsync` over SSH. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Features |
| 8 | + |
| 9 | +- **External Configuration**: All settings (paths, hosts) and credentials (API tokens) are stored in separate files, not in the script. |
| 10 | +- **Portable**: The entire backup setup (script + configs) can be moved to a new server by copying one directory. |
| 11 | +- **ntfy Notifications**: Sends detailed success or failure notifications to a specified ntfy topic. |
| 12 | +- **Robust Error Handling**: Uses `set -Euo pipefail` and a global `ERR` trap to catch and report any unexpected errors. |
| 13 | +- **Locking**: Prevents multiple instances from running simultaneously using `flock`. Essential for cron jobs. |
| 14 | +- **Log Rotation**: Automatically rotates the log file when it exceeds a defined size. |
| 15 | +- **Special Modes**: Includes modes for `--dry-run`, integrity checking (`--checksum`), and getting a quick summary (`--summary`). |
| 16 | +- **Prerequisite Checks**: Verifies that all required commands and SSH connectivity are working before running. |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## File Structure |
| 21 | + |
| 22 | +All files should be placed in a single directory (e.g., `/root/scripts/backup`). |
| 23 | + |
| 24 | +``` |
| 25 | +
|
| 26 | +/root/scripts/backup/ |
| 27 | +├── backup_script.sh (The main script) |
| 28 | +├── backup_rsync.conf (Your main backup settings) |
| 29 | +├── credentials.conf (Your secret token and ntfy URL) |
| 30 | +└── rsync_exclude.txt (Files and patterns to exclude) |
| 31 | +
|
| 32 | +```` |
| 33 | +
|
| 34 | +--- |
| 35 | +
|
| 36 | +## Setup Instructions |
| 37 | +
|
| 38 | +Follow these steps to get the backup system running. |
| 39 | +
|
| 40 | +### 1. Prerequisites |
| 41 | +
|
| 42 | +First, ensure the required tools are installed. On Debian/Ubuntu, you can run: |
| 43 | +```sh |
| 44 | +sudo apt-get update && sudo apt-get install rsync curl coreutils util-linux |
| 45 | +```` |
| 46 | +
|
| 47 | +*(coreutils provides `numfmt`, `stat`, etc. and util-linux provides `flock`)* |
| 48 | +
|
| 49 | +### 2\. Passwordless SSH Login |
| 50 | +
|
| 51 | +The script needs to log into the Hetzner Storage Box without a password. |
| 52 | +
|
| 53 | + - **Generate an SSH key** on your server if you don't have one: |
| 54 | +
|
| 55 | + ```sh |
| 56 | + ssh-keygen -t rsa -b 4096 |
| 57 | + ``` |
| 58 | +
|
| 59 | + (Just press Enter through all the prompts). |
| 60 | +
|
| 61 | + - **Copy your public key** to the Hetzner Storage Box. First, view your public key: |
| 62 | +
|
| 63 | + ```sh |
| 64 | + cat ~/.ssh/id_rsa.pub |
| 65 | + ``` |
| 66 | +
|
| 67 | + - Go to your Hetzner Robot panel, select your Storage Box, and paste the entire public key content into the "SSH Keys" section. |
| 68 | +
|
| 69 | + - **Test the connection**. Replace `u444300` and the hostname with your own details. |
| 70 | +
|
| 71 | + ```sh |
| 72 | + ssh -p 23 [email protected] 'echo "Connection successful"' |
| 73 | + ``` |
| 74 | +
|
| 75 | + If this works without asking for a password, you are ready. |
| 76 | +
|
| 77 | +### 3\. Place and Configure Files |
| 78 | +
|
| 79 | +1. Create your script directory: `mkdir -p /root/scripts/backup && cd /root/scripts/backup` |
| 80 | +2. Create the four files (`backup_script.sh`, `backup_rsync.conf`, `credentials.conf`, `rsync_exclude.txt`) in this directory using the content provided below. |
| 81 | +3. **Make the script executable**: |
| 82 | + ```sh |
| 83 | + chmod +x backup_script.sh |
| 84 | + ``` |
| 85 | +4. **Set secure permissions** for your credentials: |
| 86 | + ```sh |
| 87 | + chmod 600 credentials.conf |
| 88 | + ``` |
| 89 | +5. Edit `backup_rsync.conf` and `credentials.conf` to match your server paths, Hetzner details, and ntfy topic. |
| 90 | +6. Edit `rsync_exclude.txt` to list any files or directories you wish to skip. |
| 91 | +
|
| 92 | +### 4\. Set up a Cron Job |
| 93 | +
|
| 94 | +To run the backup automatically, edit the root crontab. |
| 95 | +
|
| 96 | + - Open the crontab editor: |
| 97 | + ```sh |
| 98 | + crontab -e |
| 99 | + ``` |
| 100 | + - Add a line to schedule the script. This example runs the backup every day at 3:00 AM. |
| 101 | + ```crontab |
| 102 | + # Run the rsync backup every day at 3:00 AM |
| 103 | + 0 3 * * * /root/scripts/backup/backup_script.sh >/dev/null 2>&1 |
| 104 | + ``` |
| 105 | + *(Redirecting output to `/dev/null` is fine since the script handles its own logging and notifications).* |
| 106 | +
|
| 107 | +----- |
| 108 | +
|
| 109 | +## Usage |
| 110 | +
|
| 111 | + - **Run Manually**: `cd /root/scripts/backup && ./backup_script.sh` |
| 112 | + - **Dry Run** (see what would change without doing anything): `./backup_script.sh --dry-run` |
| 113 | + - **Check Integrity** (compare local and remote files): `./backup_script.sh --checksum` |
| 114 | + - **Get Mismatch Count**: `./backup_script.sh --summary` |
| 115 | +
|
| 116 | +The log file is located at `/var/log/backup_rsync.log` by default. |
| 117 | +
|
| 118 | +----- |
| 119 | +
|
| 120 | +### **`backup_script.sh`** |
| 121 | +
|
| 122 | +```bash |
| 123 | +
|
| 124 | +#!/bin/bash |
| 125 | +
|
| 126 | +# ================================================================= |
| 127 | +# SCRIPT INITIALIZATION & SETUP |
| 128 | +# ================================================================= |
| 129 | +set -Euo pipefail |
| 130 | +umask 077 |
| 131 | +
|
| 132 | +# --- Determine script's location to load local config files --- |
| 133 | +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) |
| 134 | +
|
| 135 | +# --- Source Configuration Files --- |
| 136 | +# Non-sensitive settings from the script's directory |
| 137 | +if [ -f "${SCRIPT_DIR}/backup_rsync.conf" ]; then |
| 138 | + source "${SCRIPT_DIR}/backup_rsync.conf" |
| 139 | +else |
| 140 | + echo "FATAL: Main configuration file backup_rsync.conf not found in ${SCRIPT_DIR}." >&2 |
| 141 | + exit 1 |
| 142 | +fi |
| 143 | +# Sensitive credentials from the script's directory |
| 144 | +if [ -f "${SCRIPT_DIR}/credentials.conf" ]; then |
| 145 | + source "${SCRIPT_DIR}/credentials.conf" |
| 146 | +else |
| 147 | + echo "FATAL: Credentials file credentials.conf not found in ${SCRIPT_DIR}." >&2 |
| 148 | + exit 1 |
| 149 | +fi |
| 150 | +
|
| 151 | +
|
| 152 | +# ================================================================= |
| 153 | +# SCRIPT CONFIGURATION (STATIC) |
| 154 | +# ================================================================= |
| 155 | +# These values are less likely to change or are derived from config |
| 156 | +REMOTE_TARGET="${HETZNER_BOX}:${BOX_DIR}" |
| 157 | +LOCK_FILE="/tmp/backup_rsync.lock" |
| 158 | +MAX_LOG_SIZE=10485760 # 10 MB in bytes |
| 159 | +
|
| 160 | +RSYNC_OPTS=( |
| 161 | + -avz |
| 162 | + --stats |
| 163 | + --delete |
| 164 | + --partial |
| 165 | + --timeout=60 |
| 166 | + --exclude-from="$EXCLUDE_FROM" |
| 167 | + -e "ssh -p $SSH_PORT" |
| 168 | +) |
| 169 | +
|
| 170 | +# ================================================================= |
| 171 | +# HELPER FUNCTIONS |
| 172 | +# ================================================================= |
| 173 | +
|
| 174 | +send_ntfy() { |
| 175 | + local title="$1" |
| 176 | + local tags="$2" |
| 177 | + local priority="${3:-default}" |
| 178 | + local message="$4" |
| 179 | + # Check for token and URL to prevent curl errors |
| 180 | + if [ -z "${NTFY_TOKEN:-}" ] || [ -z "${NTFY_URL:-}" ]; then return; fi |
| 181 | + curl -s -u ":$NTFY_TOKEN" \ |
| 182 | + -H "Title: ${title}" \ |
| 183 | + -H "Tags: ${tags}" \ |
| 184 | + -H "Priority: ${priority}" \ |
| 185 | + -d "$message" \ |
| 186 | + "$NTFY_URL" > /dev/null 2>> "$LOG_FILE" |
| 187 | +} |
| 188 | +
|
| 189 | +run_integrity_check() { |
| 190 | + local rsync_check_opts=( |
| 191 | + -ainc |
| 192 | + --delete |
| 193 | + --exclude-from="$EXCLUDE_FROM" |
| 194 | + --out-format="%n" |
| 195 | + -e "ssh -p $SSH_PORT" |
| 196 | + ) |
| 197 | + LC_ALL=C rsync "${rsync_check_opts[@]}" "$LOCAL_DIR" "$REMOTE_TARGET" 2>> "$LOG_FILE" |
| 198 | +} |
| 199 | +
|
| 200 | +format_backup_stats() { |
| 201 | + local rsync_output="$1" |
| 202 | + local bytes |
| 203 | + bytes=$(echo "$rsync_output" | grep 'Total transferred file size' | awk '{gsub(/,/, ""); print $5}') |
| 204 | +
|
| 205 | + if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then |
| 206 | + printf "Data Transferred: %s" "$(numfmt --to=iec-i --suffix=B --format="%.2f" "$bytes")" |
| 207 | + else |
| 208 | + printf "Data Transferred: 0 B (No changes)" |
| 209 | + fi |
| 210 | +} |
| 211 | +
|
| 212 | +# ================================================================= |
| 213 | +# PRE-FLIGHT CHECKS & SETUP |
| 214 | +# ================================================================= |
| 215 | +
|
| 216 | +trap 'send_ntfy "❌ Backup Crashed: ${HOSTNAME}" "x" "high" "Backup script terminated unexpectedly. Check log: ${LOG_FILE}"' ERR |
| 217 | +
|
| 218 | +REQUIRED_CMDS=(rsync curl flock hostname date stat mv touch awk numfmt grep) |
| 219 | +for cmd in "${REQUIRED_CMDS[@]}"; do |
| 220 | + if ! command -v "$cmd" &>/dev/null; then |
| 221 | + echo "FATAL: Required command '$cmd' not found. Please install it." >&2 |
| 222 | + trap - ERR |
| 223 | + exit 10 |
| 224 | + fi |
| 225 | +done |
| 226 | +
|
| 227 | +if ! ssh -p "$SSH_PORT" -o BatchMode=yes -o ConnectTimeout=10 "$HETZNER_BOX" 'exit' 2>/dev/null; then |
| 228 | + send_ntfy "❌ SSH FAILED: ${HOSTNAME}" "x" "high" "Unable to SSH into $HETZNER_BOX. Check keys and connectivity." |
| 229 | + trap - ERR |
| 230 | + exit 6 |
| 231 | +fi |
| 232 | +
|
| 233 | +if ! [ -f "$EXCLUDE_FROM" ]; then |
| 234 | + send_ntfy "❌ Backup FAILED: ${HOSTNAME}" "x" "high" "FATAL: Exclude file not found at $EXCLUDE_FROM" |
| 235 | + trap - ERR |
| 236 | + exit 3 |
| 237 | +fi |
| 238 | +if [[ "$LOCAL_DIR" != */ ]]; then |
| 239 | + send_ntfy "❌ Backup FAILED: ${HOSTNAME}" "x" "high" "FATAL: LOCAL_DIR must end with a trailing slash ('/')" |
| 240 | + trap - ERR |
| 241 | + exit 2 |
| 242 | +fi |
| 243 | +
|
| 244 | +# ================================================================= |
| 245 | +# SCRIPT EXECUTION |
| 246 | +# ================================================================= |
| 247 | +
|
| 248 | +HOSTNAME=$(hostname -s) |
| 249 | +
|
| 250 | +if [[ "${1:-}" ]]; then |
| 251 | + trap - ERR |
| 252 | + case "${1}" in |
| 253 | + --dry-run) |
| 254 | + echo "--- DRY RUN MODE ACTIVATED ---" |
| 255 | + rsync "${RSYNC_OPTS[@]}" --dry-run "$LOCAL_DIR" "$REMOTE_TARGET" |
| 256 | + echo "--- DRY RUN COMPLETED ---" |
| 257 | + exit 0 |
| 258 | + ;; |
| 259 | + --checksum) |
| 260 | + echo "--- INTEGRITY CHECK MODE ACTIVATED ---" |
| 261 | + FILE_DISCREPANCIES=$(run_integrity_check) |
| 262 | + if [ -z "$FILE_DISCREPANCIES" ]; then |
| 263 | + send_ntfy "✅ Backup Integrity OK: ${HOSTNAME}" "white_check_mark" "default" "Checksum validation passed. No discrepancies found." |
| 264 | + else |
| 265 | + ISSUE_LIST=$(echo "${FILE_DISCREPANCIES}" | head -n 10) |
| 266 | + printf -v FAILURE_MSG "Backup integrity check FAILED.\n\nFirst 10 differing files:\n%s\n\nCheck log for full details." "${ISSUE_LIST}" |
| 267 | + send_ntfy "❌ Backup Integrity FAILED: ${HOSTNAME}" "x" "high" "${FAILURE_MSG}" |
| 268 | + fi |
| 269 | + exit 0 |
| 270 | + ;; |
| 271 | + --summary) |
| 272 | + echo "--- INTEGRITY SUMMARY MODE ---" |
| 273 | + MISMATCH_COUNT=$(run_integrity_check | wc -l) |
| 274 | + printf "🚨 Total files with checksum mismatches: %d\n" "$MISMATCH_COUNT" |
| 275 | + exit 0 |
| 276 | + ;; |
| 277 | + esac |
| 278 | +fi |
| 279 | +
|
| 280 | +exec 200>"$LOCK_FILE" |
| 281 | +flock -n 200 || { echo "Another instance is running, exiting."; exit 5; } |
| 282 | +
|
| 283 | +if [ -f "$LOG_FILE" ] && [ "$(stat -c%s "$LOG_FILE")" -gt "$MAX_LOG_SIZE" ]; then |
| 284 | + mv "$LOG_FILE" "${LOG_FILE}.$(date +%Y%m%d_%H%M%S)" |
| 285 | + touch "$LOG_FILE" |
| 286 | +fi |
| 287 | +
|
| 288 | +echo "============================================================" >> "$LOG_FILE" |
| 289 | +echo "[$HOSTNAME] [$(date '+%Y-%m-%d %H:%M:%S')] Starting rsync backup" >> "$LOG_FILE" |
| 290 | +
|
| 291 | +# --- Execute Backup & Capture Output --- |
| 292 | +RSYNC_OUTPUT=$(rsync "${RSYNC_OPTS[@]}" "$LOCAL_DIR" "$REMOTE_TARGET" 2>&1) |
| 293 | +RSYNC_EXIT_CODE=$? |
| 294 | +
|
| 295 | +# Log the full output from the command |
| 296 | +echo "$RSYNC_OUTPUT" >> "$LOG_FILE" |
| 297 | +
|
| 298 | +if [ $RSYNC_EXIT_CODE -eq 0 ]; then |
| 299 | + trap - ERR |
| 300 | + echo "[$HOSTNAME] [$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: rsync completed." >> "$LOG_FILE" |
| 301 | + BACKUP_STATS=$(format_backup_stats "$RSYNC_OUTPUT") |
| 302 | + printf -v SUCCESS_MSG "rsync backup completed successfully.\n\n%s" "${BACKUP_STATS}" |
| 303 | + send_ntfy "✅ Backup SUCCESS: ${HOSTNAME}" "white_check_mark" "default" "${SUCCESS_MSG}" |
| 304 | +else |
| 305 | + EXIT_CODE=$RSYNC_EXIT_CODE |
| 306 | + trap - ERR |
| 307 | + echo "[$HOSTNAME] [$(date '+%Y-%m-%d %H:%M:%S')] FAILED: rsync exited with code: $EXIT_CODE." >> "$LOG_FILE" |
| 308 | + send_ntfy "❌ Backup FAILED: ${HOSTNAME}" "x" "high" "rsync failed on ${HOSTNAME} with exit code ${EXIT_CODE}. Check log for details." |
| 309 | +fi |
| 310 | +
|
| 311 | +echo "======================= Run Finished =======================" >> "$LOG_FILE" |
| 312 | +echo "" >> "$LOG_FILE" |
| 313 | +
|
| 314 | +```` |
| 315 | +
|
| 316 | +----- |
| 317 | +
|
| 318 | +### **`backup_rsync.conf`** |
| 319 | +
|
| 320 | +```bash |
| 321 | +# Configuration for the rsync backup script |
| 322 | +
|
| 323 | +# --- Source and Destination --- |
| 324 | +# IMPORTANT: LOCAL_DIR must end with a trailing slash! |
| 325 | +LOCAL_DIR="/home/user/" |
| 326 | +
|
| 327 | +# Directory on the remote storage box |
| 328 | +BOX_DIR="/home/myvps/" |
| 329 | +
|
| 330 | +# Hetzner Storage Box details (username and hostname) |
| 331 | + |
| 332 | +SSH_PORT="23" |
| 333 | +
|
| 334 | +# --- Logging --- |
| 335 | +LOG_FILE="/var/log/backup_rsync.log" |
| 336 | +
|
| 337 | +# --- Exclude File --- |
| 338 | +# This line uses the SCRIPT_DIR variable from the main script |
| 339 | +# to locate rsync_exclude.txt in the same directory. |
| 340 | +EXCLUDE_FROM="${SCRIPT_DIR}/rsync_exclude.txt" |
| 341 | +
|
| 342 | +``` |
| 343 | + |
| 344 | +----- |
| 345 | + |
| 346 | +### **`credentials.conf`** |
| 347 | + |
| 348 | +```ini |
| 349 | +# Sensitive information for the backup script |
| 350 | +# !! IMPORTANT !! Set permissions to 600: chmod 600 credentials.conf |
| 351 | + |
| 352 | +NTFY_TOKEN="tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" |
| 353 | +NTFY_URL="https://ntfy.sh/your-private-topic-name" |
| 354 | + |
| 355 | +``` |
| 356 | + |
| 357 | +----- |
| 358 | + |
| 359 | +### **`rsync_exclude.txt`** |
| 360 | + |
| 361 | +``` |
| 362 | +# List of files/directories to exclude from backup, one per line. |
| 363 | +# See 'man rsync' for pattern matching rules. |
| 364 | +
|
| 365 | +# Common cache and temporary files |
| 366 | +.cache/ |
| 367 | +/tmp/ |
| 368 | +*.tmp |
| 369 | +*.bak |
| 370 | +*.swp |
| 371 | +
|
| 372 | +# Specific application caches/dependencies |
| 373 | +/node_modules/ |
| 374 | +/vendor/ |
| 375 | +__pycache__/ |
| 376 | +
|
| 377 | +# System files that shouldn't be backed up |
| 378 | +/lost+found/ |
| 379 | +.DS_Store |
| 380 | +Thumbs.db |
| 381 | +``` |
0 commit comments