#!/bin/bash # Backup ERPNext Site Script # Usage: ./backup-site.sh [options] set -euo pipefail # Colors RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; } echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } echo_error() { echo -e "${RED}[ERROR]${NC} $1"; } echo_debug() { [[ "${DEBUG:-0}" == "1" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; } log_action() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "/tmp/erpnext-backup-$(date '+%Y%m%d').log"; } cleanup() { local exit_code=$? if [[ $exit_code -ne 0 ]]; then echo_error "Script failed with exit code $exit_code" log_action "FAILED: ${SITE_NAME:-unknown}" fi exit $exit_code } trap cleanup EXIT # Configuration PROJECT_NAME="${PROJECT_NAME:-erpnext-production}" BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" HOST_BACKUP_ROOT="${HOST_BACKUP_ROOT:-./backups}" HOST_BACKUP_LAYOUT="${HOST_BACKUP_LAYOUT:-flat}" AUTO_COPY="${AUTO_COPY:-0}" CLEANUP_OLD="${CLEANUP_OLD:-0}" CLEANUP_POLICY="${CLEANUP_POLICY:-}" HOST_ONLY="${HOST_ONLY:-0}" COMPOSE_FILE="${COMPOSE_FILE:-production.yaml}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PRODUCTION_DIR="$(dirname "$SCRIPT_DIR")" COMPOSE_PATH="$PRODUCTION_DIR/$COMPOSE_FILE" cd "$PRODUCTION_DIR" [[ -f "$COMPOSE_PATH" ]] || { echo_error "Compose file '$COMPOSE_PATH' not found"; exit 1; } dc_exec() { docker compose --project-name "$PROJECT_NAME" -f "$COMPOSE_PATH" exec backend "$@"; } dc_cmd() { docker compose --project-name "$PROJECT_NAME" -f "$COMPOSE_PATH" "$@"; } show_help() { cat < [options] Options: --with-files Include public/private files in the bench backup --compress Compress the SQL dump (bench flag) --auto-copy Copy the new backup files to the host (flat layout by default) --flat-host-path Store host copies directly in \$HOST_BACKUP_ROOT (default) --nested-host-path Store host copies in \$HOST_BACKUP_ROOT/// --host-only Delete container copies after a successful host copy --cleanup-old[=policy] Remove stale backups. Policy examples: (empty) → BACKUP_RETENTION_DAYS days 7 → files older than 7 days keep:5 → keep newest 5 runs latest → keep only backups from this run --retention-days N Deprecated alias for --cleanup-old N --encrypt Encrypt host copies with GPG (needs BACKUP_PASSPHRASE) --debug Verbose logging -h, --help Show this help Environment Variables: PROJECT_NAME Docker Compose project name (default: erpnext-production) BACKUP_RETENTION_DAYS Default days to keep when no policy passed (default: 30) HOST_BACKUP_ROOT Host destination directory (default: ./backups) HOST_BACKUP_LAYOUT "flat" (default) or "nested" AUTO_COPY Set to 1 to copy on every run HOST_ONLY Set to 1 to delete container copies when AUTO_COPY=1 CLEANUP_OLD Set to 1 to always prune when script runs CLEANUP_POLICY Default cleanup policy (keep:7, latest, etc.) BACKUP_PASSPHRASE Required when --encrypt is used COMPOSE_FILE Compose file relative to production/ (default: production.yaml) Examples: $0 erp.example.com --with-files --auto-copy --cleanup-old keep:7 $0 erp.example.com --with-files --auto-copy --host-only --cleanup-old latest AUTO_COPY=1 CLEANUP_OLD=1 CLEANUP_POLICY=keep:5 $0 erp.example.com EOF } require_int() { local value="$1"; shift [[ "$value" =~ ^[0-9]+$ ]] || { echo_error "$*"; exit 1; } } set_policy() { [[ "$CLEANUP_OLD" -ne 1 ]] && { CLEANUP_MODE=""; CLEANUP_VALUE=""; return; } local policy_value="$1" [[ -z "$policy_value" ]] && policy_value="$BACKUP_RETENTION_DAYS" case "$policy_value" in latest|keep-latest) CLEANUP_MODE="keep" CLEANUP_VALUE=1 ;; keep:*) local keep_count="${policy_value#keep:}" require_int "$keep_count" "keep: expects an integer" (( keep_count < 1 )) && keep_count=1 CLEANUP_MODE="keep" CLEANUP_VALUE="$keep_count" ;; days:*) local day_count="${policy_value#days:}" require_int "$day_count" "days: expects an integer" CLEANUP_MODE="days" CLEANUP_VALUE="$day_count" ;; "") CLEANUP_MODE="days" CLEANUP_VALUE="$BACKUP_RETENTION_DAYS" ;; *) if [[ "$policy_value" =~ ^[0-9]+$ ]]; then if [[ "$policy_value" -eq 0 ]]; then CLEANUP_MODE="keep" CLEANUP_VALUE=1 else CLEANUP_MODE="days" CLEANUP_VALUE="$policy_value" fi else echo_error "Invalid cleanup policy: $policy_value" exit 1 fi ;; esac } cleanup_container_days() { local days="$1" local minutes=$((days * 1440)) echo_info "Pruning container backups older than $days day(s)" dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -mmin +$minutes -delete" || true } cleanup_container_keep() { local keep_runs="$1" echo_info "Keeping newest $keep_runs container backup run(s)" mapfile -t prefixes < <(dc_exec bash -lc "cd '$BACKUP_PATH' && ls -1t 2>/dev/null | awk -F'-' '!seen[$1]++ {print $1}'") || true if [[ ${#prefixes[@]} -le $keep_runs ]]; then echo_info "Nothing to prune in container" return fi prefixes=(${prefixes[@]:$keep_runs}) for prefix in "${prefixes[@]}"; do [[ -z "$prefix" ]] && continue dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -name '${prefix}-*' -delete" done } cleanup_host_days() { local days="$1" if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then [[ -d "$HOST_SITE_ROOT" ]] || return echo_info "Pruning host backups older than $days day(s)" find "$HOST_SITE_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +$days -print -exec rm -rf {} + || true else [[ -d "$HOST_BACKUP_ROOT" ]] || return echo_info "Pruning host backup files older than $days day(s)" find "$HOST_BACKUP_ROOT" -maxdepth 1 -type f -name "*-${SITE_FILE_KEY}-*" -mtime +$days -print -delete || true fi } cleanup_host_keep() { local keep_runs="$1" if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then [[ -d "$HOST_SITE_ROOT" ]] || return mapfile -t dirs < <(ls -1dt "$HOST_SITE_ROOT"/* 2>/dev/null) || true if [[ ${#dirs[@]} -le $keep_runs ]]; then return fi echo_info "Keeping newest $keep_runs host backup run(s)" for dir in "${dirs[@]:$keep_runs}"; do rm -rf "$dir" done else [[ -d "$HOST_BACKUP_ROOT" ]] || return mapfile -t prefixes < <(find "$HOST_BACKUP_ROOT" -maxdepth 1 -type f -name "*-${SITE_FILE_KEY}-*" -printf '%f\n' | sort -r | awk -F'-' '!seen[$1]++ {print $1}') || true if [[ ${#prefixes[@]} -le $keep_runs ]]; then return fi echo_info "Keeping newest $keep_runs host backup run(s)" prefixes=(${prefixes[@]:$keep_runs}) for prefix in "${prefixes[@]}"; do find "$HOST_BACKUP_ROOT" -maxdepth 1 -type f -name "${prefix}-${SITE_FILE_KEY}-*" -delete done fi } copy_backups_to_host() { [[ "$AUTO_COPY" -ne 1 ]] && return [[ ${#BACKUP_FILES[@]} -eq 0 ]] && return local dest if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then dest="$HOST_SITE_ROOT/$CURRENT_PREFIX" else dest="$HOST_BACKUP_ROOT" fi mkdir -p "$dest" BACKEND_CONTAINER=$(dc_cmd ps -q backend) [[ -z "$BACKEND_CONTAINER" ]] && { echo_error "Backend container not running"; exit 1; } HOST_RUN_FILES=() for file_path in "${BACKUP_FILES[@]}"; do local base target base=$(basename "$file_path") target="$dest/$base" if docker cp "${BACKEND_CONTAINER}:${file_path}" "$target" 2>/dev/null; then echo_info " → Copied $base" HOST_RUN_FILES+=("$target") else echo_warn " ✗ Failed to copy $base" fi done if [[ ${#HOST_RUN_FILES[@]} -gt 0 ]]; then HOST_COPY_SUCCESS=1 HOST_RUN_DIR="$dest" echo_info "✓ Host copy complete: $dest" else echo_warn "No files copied to host" fi } encrypt_host_backups() { [[ "$ENCRYPT" -ne 1 ]] && return [[ "$HOST_COPY_SUCCESS" -ne 1 ]] && { echo_warn "Cannot encrypt – host copy missing"; return; } command -v gpg >/dev/null 2>&1 || { echo_error "GPG not installed"; exit 1; } [[ -z "${BACKUP_PASSPHRASE:-}" ]] && { echo_error "BACKUP_PASSPHRASE not set"; exit 1; } local encrypted=0 for backup_file in "${HOST_RUN_FILES[@]}"; do [[ -f "$backup_file" ]] || continue if echo "$BACKUP_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \ --symmetric --cipher-algo AES256 -o "${backup_file}.gpg" "$backup_file"; then rm -f "$backup_file" ((encrypted++)) echo_info " ✓ Encrypted $(basename "${backup_file}.gpg")" else echo_warn " ✗ Encryption failed for $(basename "$backup_file")" fi done echo_info "Encrypted $encrypted file(s)" log_action "SUCCESS: Encrypted $encrypted files" } remove_container_backups() { [[ "$HOST_ONLY" -ne 1 ]] && return echo_info "Removing container backups (host-only mode)" dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -delete" || true } verify_backup_presence() { if [[ "$AUTO_COPY" -eq 1 ]]; then if [[ "$HOST_COPY_SUCCESS" -ne 1 ]] || [[ ${#HOST_RUN_FILES[@]} -eq 0 ]]; then echo_error "Host backup missing after copy attempt" exit 1 fi for backup_file in "${HOST_RUN_FILES[@]}"; do [[ -f "$backup_file" || -f "${backup_file}.gpg" ]] && continue echo_error "Host backup file missing: $(basename "$backup_file")" exit 1 done else for base in "${BACKUP_BASENAMES[@]}"; do if ! dc_exec test -f "$BACKUP_PATH/$base"; then echo_error "Container backup file missing: $base" exit 1 fi done fi } print_host_summary() { [[ "$AUTO_COPY" -ne 1 ]] && { echo_info "Use --auto-copy to mirror backups onto the host"; return; } if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then echo_info "Latest host backups:" ls -lht "$HOST_SITE_ROOT" 2>/dev/null | head -n 6 || true else echo_info "Latest host backup files:" if [[ -d "$HOST_BACKUP_ROOT" ]]; then (ls -lht "$HOST_BACKUP_ROOT" 2>/dev/null | grep "$SITE_FILE_KEY" | head -n 6) || true fi fi } # Runtime values SITE_NAME="" WITH_FILES=0 COMPRESS=0 ENCRYPT=0 DEBUG="${DEBUG:-0}" CLEANUP_MODE="" CLEANUP_VALUE="" HOST_LAYOUT_OVERRIDE="" HOST_LAYOUT_MODE="" HOST_SITE_ROOT="" SITE_SAFE_NAME="" SITE_FILE_KEY="" BACKUP_PATH="" MARKER="" CURRENT_PREFIX="" BACKEND_CONTAINER="" HOST_COPY_SUCCESS=0 HOST_RUN_DIR="" declare -a BACKUP_FILES=() declare -a BACKUP_BASENAMES=() declare -a HOST_RUN_FILES=() # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in -h|--help) show_help; exit 0 ;; --with-files) WITH_FILES=1; shift ;; --compress) COMPRESS=1; shift ;; --auto-copy) AUTO_COPY=1; shift ;; --host-only) HOST_ONLY=1; AUTO_COPY=1; shift ;; --flat-host-path) AUTO_COPY=1; HOST_LAYOUT_OVERRIDE="flat"; shift ;; --nested-host-path) AUTO_COPY=1; HOST_LAYOUT_OVERRIDE="nested"; shift ;; --cleanup-old) CLEANUP_OLD=1 if [[ -n "${2:-}" && ! "${2}" =~ ^- ]]; then CLEANUP_POLICY="$2" shift 2 else shift fi ;; --cleanup-old=*) CLEANUP_OLD=1 CLEANUP_POLICY="${1#*=}" shift ;; --retention-days) CLEANUP_OLD=1 [[ -n "${2:-}" ]] || { echo_error "--retention-days needs an integer"; exit 1; } CLEANUP_POLICY="$2" shift 2 ;; --retention-days=*) CLEANUP_OLD=1 CLEANUP_POLICY="${1#*=}" shift ;; --encrypt) ENCRYPT=1; shift ;; --debug) DEBUG=1; shift ;; *) if [[ -z "$SITE_NAME" ]]; then SITE_NAME="$1" shift else echo_error "Unknown argument: $1" show_help exit 1 fi ;; esac done if [[ -z "$SITE_NAME" ]]; then read -rp "Enter site name (e.g., erp.example.com): " SITE_NAME fi [[ -z "$SITE_NAME" ]] && { echo_error "Site name is required"; exit 1; } [[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo_error "BACKUP_RETENTION_DAYS must be a non-negative integer"; exit 1; } HOST_LAYOUT_MODE="${HOST_LAYOUT_OVERRIDE:-$HOST_BACKUP_LAYOUT}" HOST_LAYOUT_MODE="${HOST_LAYOUT_MODE,,}" case "$HOST_LAYOUT_MODE" in flat|nested) ;; *) echo_warn "Unknown HOST_BACKUP_LAYOUT '$HOST_LAYOUT_MODE', defaulting to flat"; HOST_LAYOUT_MODE="flat" ;; esac SITE_SAFE_NAME="${SITE_NAME//[^A-Za-z0-9._-]/_}" SITE_FILE_KEY="$(echo "$SITE_NAME" | sed 's/[^A-Za-z0-9]/_/g')" HOST_SITE_ROOT="$HOST_BACKUP_ROOT/$SITE_SAFE_NAME" BACKUP_PATH="/home/frappe/frappe-bench/sites/$SITE_NAME/private/backups" MARKER="/tmp/backup-${SITE_NAME//[^A-Za-z0-9]/-}-$$.marker" set_policy "$CLEANUP_POLICY" echo_debug "Site: $SITE_NAME" docker info >/dev/null 2>&1 || { echo_error "Docker not running"; exit 1; } dc_exec echo "test" >/dev/null 2>&1 || { echo_error "Backend container not running"; exit 1; } echo_info "Verifying site: $SITE_NAME" if ! dc_exec bench --site "$SITE_NAME" list-apps >/dev/null 2>&1; then echo_error "Site '$SITE_NAME' not found" exit 1 fi dc_exec bash -lc "touch '$MARKER'" echo_info "Creating backup for: $SITE_NAME" log_action "STARTED: $SITE_NAME" bench_cmd=(bench --site "$SITE_NAME" backup) (( WITH_FILES )) && bench_cmd+=(--with-files) (( COMPRESS )) && bench_cmd+=(--compress) if ! dc_exec "${bench_cmd[@]}"; then echo_error "Backup command failed" log_action "FAILED: backup command" exit 1 fi mapfile -t BACKUP_FILES < <(dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -newer '$MARKER' -print") || true dc_exec bash -lc "rm -f '$MARKER'" || true if [[ ${#BACKUP_FILES[@]} -eq 0 ]]; then echo_error "No backup files detected" log_action "FAILED: no files" exit 1 fi TOTAL_SIZE=0 for file_path in "${BACKUP_FILES[@]}"; do [[ -z "$file_path" ]] && continue base=$(basename "$file_path") BACKUP_BASENAMES+=("$base") size_bytes=$(dc_exec stat -c%s "$file_path" 2>/dev/null | tr -d '\r' || echo "0") TOTAL_SIZE=$((TOTAL_SIZE + size_bytes)) size_hr=$(numfmt --to=iec-i --suffix=B "$size_bytes" 2>/dev/null || echo "$size_bytes bytes") echo_info " - $base ($size_hr)" done CURRENT_PREFIX="${BACKUP_BASENAMES[0]%%-*}" [[ -z "$CURRENT_PREFIX" ]] && CURRENT_PREFIX="$(date '+%Y%m%d_%H%M%S')" [[ $TOTAL_SIZE -gt 0 ]] && echo_info "Total size: $(numfmt --to=iec-i --suffix=B "$TOTAL_SIZE" 2>/dev/null || echo "$TOTAL_SIZE bytes")" log_action "SUCCESS: ${#BACKUP_FILES[@]} files, $TOTAL_SIZE bytes" if [[ "$AUTO_COPY" -eq 1 ]]; then if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then echo_info "Copying backups to $HOST_SITE_ROOT/$CURRENT_PREFIX" else echo_info "Copying backups to $HOST_BACKUP_ROOT" fi copy_backups_to_host encrypt_host_backups else echo_info "Backups stored inside container: $BACKUP_PATH" fi remove_container_backups if [[ "$CLEANUP_OLD" -eq 1 ]]; then if [[ "$CLEANUP_MODE" == "days" ]]; then cleanup_container_days "$CLEANUP_VALUE" [[ "$AUTO_COPY" -eq 1 ]] && cleanup_host_days "$CLEANUP_VALUE" elif [[ "$CLEANUP_MODE" == "keep" ]]; then cleanup_container_keep "$CLEANUP_VALUE" [[ "$AUTO_COPY" -eq 1 ]] && cleanup_host_keep "$CLEANUP_VALUE" fi fi verify_backup_presence print_host_summary echo_info "✓ Backup completed successfully!"