nextcloud-backup/nextcloud-backup.sh

423 lines
13 KiB
Bash
Executable file

#!/bin/bash
#
# Nextcloud Backup Script v2.0 - NO SUDO REQUIRED
# Runs as: backup-user (member of www-data and docker groups)
#
# Backup strategy:
# - Daily: Metadata (database + config + docker files)
# - Weekly (Monday): Full sync of data
# - Daily (Tuesday-Sunday): Incremental copy of changed files
# - Yearly (January 1st): Full sync to yearly/
#
set -euo pipefail
# ============================================================================
# CONFIGURATION
# ============================================================================
# Paths
NEXTCLOUD_DOCKER_ROOT="/mnt/data/docker-stacks/nextcloud-stack"
NEXTCLOUD_DATA_ROOT="/mnt/data/docker/volumes/nextcloud-stack_nextcloud-data/_data"
BACKUP_ROOT="/opt/backup"
LOCAL_BACKUP_DIR="$BACKUP_ROOT/backups"
LOG_DIR="$BACKUP_ROOT/logs"
RCLONE_CONFIG="/opt/backup/.config/rclone/rclone.conf"
# Docker containers
DB_CONTAINER="nextcloud-db"
APP_CONTAINER="nextcloud-app"
# Rclone remote (change to your remote name)
RCLONE_REMOTE="jottacloud:ServerBackup/Nextcloud"
# Retention (number of backups to keep)
DAILY_RETENTION=10
WEEKLY_RETENTION=10
YEARLY_RETENTION=10
# ============================================================================
# LOGGING
# ============================================================================
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
LOG_FILE="$LOG_DIR/backup-$TIMESTAMP.log"
# Ensure log directory exists
mkdir -p "$LOG_DIR"
log() {
echo "[$(date +%Y-%m-%d\ %H:%M:%S)] $*" | tee -a "$LOG_FILE"
}
log_error() {
echo "[$(date +%Y-%m-%d\ %H:%M:%S)] ERROR: $*" | tee -a "$LOG_FILE" >&2
}
# ============================================================================
# ERROR HANDLING
# ============================================================================
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log_error "Backup failed with exit code: $exit_code"
else
log "✅ Backup completed successfully"
fi
exit $exit_code
}
trap cleanup EXIT
# ============================================================================
# ENVIRONMENT VALIDATION
# ============================================================================
log "========================================="
log "🚀 Starting Nextcloud Backup v2.0"
log "========================================="
log "User: $(whoami)"
log "Groups: $(groups)"
log "Timestamp: $TIMESTAMP"
log ""
# Check required commands
for cmd in docker rclone tar gzip; do
if ! command -v $cmd &> /dev/null; then
log_error "Required command not found: $cmd"
exit 1
fi
done
# Verify we're running as backup-user
if [ "$(whoami)" != "backup-user" ]; then
log_error "This script must run as backup-user"
log_error "Current user: $(whoami)"
exit 1
fi
# Verify group membership
if ! groups | grep -q www-data; then
log_error "backup-user is not in www-data group"
exit 1
fi
if ! groups | grep -q docker; then
log_error "backup-user is not in docker group"
exit 1
fi
# Verify rclone config exists
if [ ! -f "$RCLONE_CONFIG" ]; then
log_error "rclone config not found: $RCLONE_CONFIG"
log_error "Run: sudo -u backup-user rclone config"
exit 1
fi
# Test rclone connection
log "Testing rclone connection..."
if ! rclone about "$RCLONE_REMOTE" --config "$RCLONE_CONFIG" &> /dev/null; then
log_error "rclone connection test failed"
exit 1
fi
log "✅ rclone connection OK"
# ============================================================================
# DETERMINE BACKUP TYPE
# ============================================================================
CURRENT_DATE=$(date +%Y-%m-%d)
DAY_OF_WEEK=$(date +%u) # 1 = Monday, 7 = Sunday
MONTH_DAY=$(date +%m-%d)
WEEK_NUMBER=$(date +%Y-W%V)
YEAR=$(date +%Y)
# Determine if this is a weekly backup day (Monday)
IS_WEEKLY_BACKUP=false
if [ "$DAY_OF_WEEK" = "1" ]; then
IS_WEEKLY_BACKUP=true
log "📅 Weekly backup day (Monday)"
fi
# Determine if this is yearly backup day (January 1st)
IS_YEARLY_BACKUP=false
if [ "$MONTH_DAY" = "01-01" ]; then
IS_YEARLY_BACKUP=true
log "📅 Yearly backup day (January 1st)"
fi
# ============================================================================
# METADATA BACKUP (DAILY)
# ============================================================================
log ""
log "========================================="
log "📦 METADATA BACKUP"
log "========================================="
DAILY_BACKUP_PATH="$RCLONE_REMOTE/daily/$TIMESTAMP"
METADATA_DIR="$LOCAL_BACKUP_DIR/metadata-$TIMESTAMP"
mkdir -p "$METADATA_DIR"
# 1. Database backup
log "Backing up database..."
DB_FILE="$METADATA_DIR/database.sql"
if docker exec "$DB_CONTAINER" mysqldump \
-u root \
-p"$(grep MYSQL_ROOT_PASSWORD "$NEXTCLOUD_DOCKER_ROOT/.env" | cut -d= -f2)" \
--single-transaction \
--quick \
--lock-tables=false \
nextcloud > "$DB_FILE" 2>> "$LOG_FILE"; then
gzip "$DB_FILE"
DB_SIZE=$(du -h "$DB_FILE.gz" | cut -f1)
log "✅ Database backup: $DB_SIZE"
else
log_error "Database backup failed"
exit 1
fi
# 2. Config backup
log "Backing up config..."
CONFIG_TAR="$METADATA_DIR/config.tar.gz"
if tar -czf "$CONFIG_TAR" -C "$NEXTCLOUD_DATA_ROOT" config/ 2>> "$LOG_FILE"; then
CONFIG_SIZE=$(du -h "$CONFIG_TAR" | cut -f1)
log "✅ Config backup: $CONFIG_SIZE"
else
log_error "Config backup failed"
exit 1
fi
# 3. Docker stack files backup
log "Backing up Docker stack files..."
DOCKER_TAR="$METADATA_DIR/docker-stack.tar.gz"
if tar -czf "$DOCKER_TAR" \
-C "$NEXTCLOUD_DOCKER_ROOT" \
docker-compose.yml \
.env 2>> "$LOG_FILE"; then
DOCKER_SIZE=$(du -h "$DOCKER_TAR" | cut -f1)
log "✅ Docker stack backup: $DOCKER_SIZE"
else
log_error "Docker stack backup failed"
exit 1
fi
# 4. Upload metadata to Jottacloud
log "Uploading metadata to Jottacloud..."
if rclone copy "$METADATA_DIR" "$DAILY_BACKUP_PATH/metadata/" \
--config "$RCLONE_CONFIG" \
--log-file "$LOG_FILE" \
--log-level INFO \
--stats 1m \
--progress 2>&1 | tee -a "$LOG_FILE"; then
log "✅ Metadata uploaded to $DAILY_BACKUP_PATH"
else
log_error "Metadata upload failed"
exit 1
fi
# Cleanup local metadata backup
rm -rf "$METADATA_DIR"
log "🧹 Cleaned up local metadata files"
# ============================================================================
# DATA BACKUP (WEEKLY + INCREMENTAL)
# ============================================================================
log ""
log "========================================="
log "💾 DATA BACKUP"
log "========================================="
DATA_SOURCE="$NEXTCLOUD_DATA_ROOT/data/"
# Check if data directory exists and is readable
if [ ! -d "$DATA_SOURCE" ]; then
log_error "Data directory not found: $DATA_SOURCE"
exit 1
fi
if [ ! -r "$DATA_SOURCE" ]; then
log_error "Data directory not readable: $DATA_SOURCE"
log_error "Verify that backup-user is in www-data group"
exit 1
fi
DATA_SIZE=$(du -sh "$DATA_SOURCE" 2>/dev/null | cut -f1 || echo "unknown")
log "Data directory size: $DATA_SIZE"
# Weekly backup: Full sync (Monday)
if [ "$IS_WEEKLY_BACKUP" = true ]; then
log "🔄 Weekly full sync to: weekly/$WEEK_NUMBER/data/"
WEEKLY_DEST="$RCLONE_REMOTE/weekly/$WEEK_NUMBER/data/"
if rclone sync "$DATA_SOURCE" "$WEEKLY_DEST" \
--config "$RCLONE_CONFIG" \
--log-file "$LOG_FILE" \
--log-level INFO \
--stats 5m \
--transfers 4 \
--checkers 8 \
--exclude '.Trash-*/**' \
--exclude '**/.~*' \
--exclude '**/~$*' \
--progress 2>&1 | tee -a "$LOG_FILE"; then
log "✅ Weekly data sync completed"
else
log_error "Weekly data sync failed"
exit 1
fi
# Also copy metadata to weekly folder
log "Copying metadata to weekly folder..."
rclone copy "$DAILY_BACKUP_PATH/metadata/" "$RCLONE_REMOTE/weekly/$WEEK_NUMBER/metadata/" \
--config "$RCLONE_CONFIG" \
--log-file "$LOG_FILE" 2>&1 | tee -a "$LOG_FILE"
else
# Daily incremental: Copy only new/changed files (Tuesday-Sunday)
log "📋 Daily incremental copy to: weekly/$WEEK_NUMBER/data/"
WEEKLY_DEST="$RCLONE_REMOTE/weekly/$WEEK_NUMBER/data/"
if rclone copy "$DATA_SOURCE" "$WEEKLY_DEST" \
--config "$RCLONE_CONFIG" \
--log-file "$LOG_FILE" \
--log-level INFO \
--stats 5m \
--update \
--transfers 4 \
--checkers 8 \
--exclude '.Trash-*/**' \
--exclude '**/.~*' \
--exclude '**/~$*' \
--progress 2>&1 | tee -a "$LOG_FILE"; then
log "✅ Daily incremental copy completed"
else
log_error "Daily incremental copy failed"
exit 1
fi
fi
# ============================================================================
# YEARLY BACKUP (January 1st)
# ============================================================================
if [ "$IS_YEARLY_BACKUP" = true ]; then
log ""
log "========================================="
log "🗓️ YEARLY BACKUP"
log "========================================="
YEARLY_DEST="$RCLONE_REMOTE/yearly/$YEAR/data/"
log "🔄 Yearly full sync to: yearly/$YEAR/data/"
if rclone sync "$DATA_SOURCE" "$YEARLY_DEST" \
--config "$RCLONE_CONFIG" \
--log-file "$LOG_FILE" \
--log-level INFO \
--stats 5m \
--transfers 4 \
--checkers 8 \
--exclude '.Trash-*/**' \
--exclude '**/.~*' \
--exclude '**/~$*' \
--progress 2>&1 | tee -a "$LOG_FILE"; then
log "✅ Yearly data sync completed"
else
log_error "Yearly data sync failed"
exit 1
fi
# Copy metadata to yearly folder
log "Copying metadata to yearly folder..."
rclone copy "$DAILY_BACKUP_PATH/metadata/" "$RCLONE_REMOTE/yearly/$YEAR/metadata/" \
--config "$RCLONE_CONFIG" \
--log-file "$LOG_FILE" 2>&1 | tee -a "$LOG_FILE"
fi
# ============================================================================
# ROTATION (CLEANUP OLD BACKUPS)
# ============================================================================
log ""
log "========================================="
log "🧹 ROTATION"
log "========================================="
# Daily rotation
log "Rotating daily backups (keeping $DAILY_RETENTION)..."
DAILY_BACKUPS=$(rclone lsf "$RCLONE_REMOTE/daily/" --config "$RCLONE_CONFIG" --dirs-only 2>/dev/null | sort -r || echo "")
DAILY_COUNT=$(echo "$DAILY_BACKUPS" | grep -c . || echo 0)
if [ "$DAILY_COUNT" -gt "$DAILY_RETENTION" ]; then
DAILY_TO_DELETE=$(echo "$DAILY_BACKUPS" | tail -n +$((DAILY_RETENTION + 1)))
echo "$DAILY_TO_DELETE" | while read -r dir; do
if [ -n "$dir" ]; then
log " Deleting old daily backup: $dir"
rclone purge "$RCLONE_REMOTE/daily/$dir" --config "$RCLONE_CONFIG" 2>> "$LOG_FILE"
fi
done
fi
log "✅ Daily retention: $DAILY_COUNT backups"
# Weekly rotation
log "Rotating weekly backups (keeping $WEEKLY_RETENTION)..."
WEEKLY_BACKUPS=$(rclone lsf "$RCLONE_REMOTE/weekly/" --config "$RCLONE_CONFIG" --dirs-only 2>/dev/null | sort -r || echo "")
WEEKLY_COUNT=$(echo "$WEEKLY_BACKUPS" | grep -c . || echo 0)
if [ "$WEEKLY_COUNT" -gt "$WEEKLY_RETENTION" ]; then
WEEKLY_TO_DELETE=$(echo "$WEEKLY_BACKUPS" | tail -n +$((WEEKLY_RETENTION + 1)))
echo "$WEEKLY_TO_DELETE" | while read -r dir; do
if [ -n "$dir" ]; then
log " Deleting old weekly backup: $dir"
rclone purge "$RCLONE_REMOTE/weekly/$dir" --config "$RCLONE_CONFIG" 2>> "$LOG_FILE"
fi
done
fi
log "✅ Weekly retention: $WEEKLY_COUNT backups"
# Yearly rotation
log "Rotating yearly backups (keeping $YEARLY_RETENTION)..."
YEARLY_BACKUPS=$(rclone lsf "$RCLONE_REMOTE/yearly/" --config "$RCLONE_CONFIG" --dirs-only 2>/dev/null | sort -r || echo "")
YEARLY_COUNT=$(echo "$YEARLY_BACKUPS" | grep -c . || echo 0)
if [ "$YEARLY_COUNT" -gt "$YEARLY_RETENTION" ]; then
YEARLY_TO_DELETE=$(echo "$YEARLY_BACKUPS" | tail -n +$((YEARLY_RETENTION + 1)))
echo "$YEARLY_TO_DELETE" | while read -r dir; do
if [ -n "$dir" ]; then
log " Deleting old yearly backup: $dir"
rclone purge "$RCLONE_REMOTE/yearly/$dir" --config "$RCLONE_CONFIG" 2>> "$LOG_FILE"
fi
done
fi
log "✅ Yearly retention: $YEARLY_COUNT backups"
# ============================================================================
# SUMMARY
# ============================================================================
log ""
log "========================================="
log "📊 BACKUP SUMMARY"
log "========================================="
log "Date: $CURRENT_DATE"
log "Type: $([ "$IS_YEARLY_BACKUP" = true ] && echo "Yearly + Weekly + Daily" || ([ "$IS_WEEKLY_BACKUP" = true ] && echo "Weekly + Daily" || echo "Daily"))"
log "Duration: $SECONDS seconds"
log "Log file: $LOG_FILE"
log ""
# Storage usage
log "Jottacloud storage:"
rclone about "$RCLONE_REMOTE" --config "$RCLONE_CONFIG" 2>/dev/null | head -3 | tee -a "$LOG_FILE" || log "Could not retrieve storage info"
log ""
log "========================================="
log "✅ BACKUP COMPLETED SUCCESSFULLY"
log "========================================="