#!/usr/bin/env bash # caprover-backup.sh — Comprehensive CapRover backup script # Author: Phil Connor # License: MIT # Version: 1.11 # # Backs up /captain config, Docker volumes (captain-- prefixed), # and app definitions via CapRover API. # Supports local, NFS, and S3 (via aws cli or rclone) destinations. # # Migration mode (--migrate) stops all CapRover app containers before # backing up volumes, ensuring database-consistent snapshots. Produces # a single migration tarball for transfer to a new server. # # Usage: # ./caprover-backup.sh # local backup to /backups/caprover # ./caprover-backup.sh --migrate # full server migration (stops containers) # BACKUP_DEST=s3 S3_BUCKET=my-bucket ./caprover-backup.sh # BACKUP_DEST=rclone RCLONE_REMOTE=myremote:backups ./caprover-backup.sh # BACKUP_DEST=nfs NFS_MOUNT=/mnt/nfs/backups ./caprover-backup.sh set -euo pipefail # --------------------------------------------------------------------------- # Configuration — override via environment variables # --------------------------------------------------------------------------- BACKUP_DIR="${BACKUP_DIR:-/backups/caprover}" BACKUP_DEST="${BACKUP_DEST:-local}" # local | nfs | s3 | rclone RETENTION_DAYS="${RETENTION_DAYS:-30}" DATE=$(date +%Y%m%d-%H%M%S) LOG_FILE="${LOG_FILE:-/var/log/caprover-backup.log}" MIGRATE=false # CapRover API settings (for app definition export) CAPROVER_URL="${CAPROVER_URL:-https://captain.apps.example.com}" CAPROVER_PASSWORD="${CAPROVER_PASSWORD:-}" # S3 settings S3_BUCKET="${S3_BUCKET:-}" S3_PREFIX="${S3_PREFIX:-caprover-backups}" # rclone settings RCLONE_REMOTE="${RCLONE_REMOTE:-}" # NFS settings NFS_MOUNT="${NFS_MOUNT:-/mnt/nfs/backups}" # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- log() { local msg msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1" echo "$msg" | tee -a "$LOG_FILE" } log_error() { log "ERROR: $1" } # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- for arg in "$@"; do case "$arg" in --migrate) MIGRATE=true ;; -h|--help) sed -n '2,/^$/{ s/^# \?//; p }' "$0" exit 0 ;; esac done # --------------------------------------------------------------------------- # Pre-flight checks # --------------------------------------------------------------------------- preflight() { if [ "$(id -u)" -ne 0 ]; then log_error "Run as root." exit 1 fi if ! command -v docker &>/dev/null; then log_error "Docker not found." exit 1 fi if [ "$BACKUP_DEST" = "s3" ] && ! command -v aws &>/dev/null; then log_error "aws CLI not found. Install it or use BACKUP_DEST=rclone." exit 1 fi if [ "$BACKUP_DEST" = "rclone" ] && ! command -v rclone &>/dev/null; then log_error "rclone not found." exit 1 fi mkdir -p "$BACKUP_DIR" mkdir -p "$(dirname "$LOG_FILE")" } # --------------------------------------------------------------------------- # Backup /captain directory # --------------------------------------------------------------------------- backup_captain_config() { log "Backing up /captain directory..." local dest="${BACKUP_DIR}/captain-config-${DATE}.tar.gz" if [ ! -d /captain ]; then log_error "/captain directory not found. Is CapRover installed?" return 1 fi tar czf "$dest" -C / captain log "Captain config saved: $dest ($(du -sh "$dest" | cut -f1))" } # --------------------------------------------------------------------------- # Backup Docker volumes (captain-- prefixed) # --------------------------------------------------------------------------- backup_volumes() { log "Backing up Docker volumes..." local volumes volumes=$(docker volume ls -q | grep "^captain--" || true) if [ -z "$volumes" ]; then log "No captain-- volumes found. Skipping." return 0 fi for vol in $volumes; do local app_name="${vol#captain--}" local dest="${BACKUP_DIR}/vol-${app_name}-${DATE}.tar.gz" log " Backing up volume: $vol" docker run --rm \ -v "${vol}:/source:ro" \ -v "${BACKUP_DIR}:/backup" \ alpine tar czf "/backup/vol-${app_name}-${DATE}.tar.gz" -C /source . log " Volume $vol saved: $dest ($(du -sh "$dest" | cut -f1))" done } # --------------------------------------------------------------------------- # Export app definitions via CapRover API # --------------------------------------------------------------------------- export_app_definitions() { if [ -z "$CAPROVER_PASSWORD" ]; then log "CAPROVER_PASSWORD not set. Skipping API export." return 0 fi log "Exporting app definitions via CapRover API..." # Get auth token local token token=$(curl -s -X POST "${CAPROVER_URL}/api/v2/login" \ -H "Content-Type: application/json" \ -H "x-namespace: captain" \ -d "{\"password\":\"${CAPROVER_PASSWORD}\"}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])" 2>/dev/null) || true if [ -z "$token" ]; then log_error "Failed to authenticate with CapRover API." return 1 fi # Export app definitions local dest="${BACKUP_DIR}/app-definitions-${DATE}.json" local http_code http_code=$(curl -s -o "$dest" -w "%{http_code}" \ "${CAPROVER_URL}/api/v2/user/apps/appDefinitions" \ -H "Content-Type: application/json" \ -H "x-namespace: captain" \ -H "x-captain-auth: ${token}") if [ "$http_code" = "200" ]; then log "App definitions saved: $dest" else log_error "API returned HTTP $http_code. App definitions export failed." rm -f "$dest" return 1 fi } # --------------------------------------------------------------------------- # Upload to remote destination # --------------------------------------------------------------------------- upload_remote() { case "$BACKUP_DEST" in local) log "Backup stored locally at $BACKUP_DIR" ;; nfs) log "Copying backups to NFS mount: $NFS_MOUNT" if ! mountpoint -q "$NFS_MOUNT" 2>/dev/null; then log_error "$NFS_MOUNT is not mounted." return 1 fi mkdir -p "${NFS_MOUNT}/caprover" cp "${BACKUP_DIR}"/*-"${DATE}"* "${NFS_MOUNT}/caprover/" log "Copied to NFS." ;; s3) log "Uploading backups to S3: s3://${S3_BUCKET}/${S3_PREFIX}/" for f in "${BACKUP_DIR}"/*-"${DATE}"*; do aws s3 cp "$f" "s3://${S3_BUCKET}/${S3_PREFIX}/$(basename "$f")" --quiet done log "S3 upload complete." ;; rclone) log "Uploading backups via rclone to: $RCLONE_REMOTE" for f in "${BACKUP_DIR}"/*-"${DATE}"*; do rclone copy "$f" "$RCLONE_REMOTE" --quiet done log "rclone upload complete." ;; *) log_error "Unknown BACKUP_DEST: $BACKUP_DEST" return 1 ;; esac } # --------------------------------------------------------------------------- # Retention — delete local backups older than RETENTION_DAYS # --------------------------------------------------------------------------- apply_retention() { log "Applying retention policy: deleting backups older than ${RETENTION_DAYS} days..." local count count=$(find "$BACKUP_DIR" -name "*.tar.gz" -o -name "*.json" | \ xargs -I{} find {} -mtime +"$RETENTION_DAYS" 2>/dev/null | wc -l) find "$BACKUP_DIR" \( -name "*.tar.gz" -o -name "*.json" \) \ -mtime +"$RETENTION_DAYS" -delete log "Removed $count old backup file(s)." } # --------------------------------------------------------------------------- # Migration — stop all CapRover app containers # --------------------------------------------------------------------------- stop_captain_containers() { log "Stopping all CapRover app containers..." local containers containers=$(docker ps -q --filter "label=com.docker.swarm.service.name" \ --filter "name=srv-captain--" 2>/dev/null || true) if [ -z "$containers" ]; then # fallback: stop services via Docker Swarm local services services=$(docker service ls -q --filter "name=srv-captain--" 2>/dev/null || true) if [ -n "$services" ]; then local count=0 for svc in $services; do local svc_name svc_name=$(docker service inspect --format '{{.Spec.Name}}' "$svc") log " Scaling down: $svc_name" docker service scale "$svc_name=0" --detach 2>/dev/null ((count++)) || true done log "Scaled down $count service(s). Waiting 10s for graceful shutdown..." sleep 10 else log "No CapRover app services found." fi else local count count=$(echo "$containers" | wc -w) docker stop $containers log "Stopped $count container(s). Waiting 5s..." sleep 5 fi } # --------------------------------------------------------------------------- # Migration — record service state for restore on new server # --------------------------------------------------------------------------- save_service_state() { log "Saving Docker service state..." local dest="${BACKUP_DIR}/service-state-${DATE}.json" docker service ls --format '{{json .}}' > "$dest" log "Service state saved: $dest" # Save docker info for reference (Swarm tokens, node info) local info_dest="${BACKUP_DIR}/docker-info-${DATE}.txt" docker info > "$info_dest" 2>&1 docker node ls >> "$info_dest" 2>/dev/null || true log "Docker info saved: $info_dest" } # --------------------------------------------------------------------------- # Migration — package everything into a single tarball # --------------------------------------------------------------------------- create_migration_bundle() { local bundle="${BACKUP_DIR}/caprover-migration-${DATE}.tar.gz" log "Creating migration bundle..." # Collect all files from this run tar czf "$bundle" -C "$BACKUP_DIR" \ $(ls -1 "$BACKUP_DIR" | grep "$DATE" | grep -v "caprover-migration") local size size=$(du -sh "$bundle" | cut -f1) log "Migration bundle ready: $bundle ($size)" log "" log "=========================================" log " MIGRATION INSTRUCTIONS" log "=========================================" log "1. Copy bundle to new server:" log " scp $bundle root@new-server:/backups/" log "" log "2. On the new server, install CapRover:" log " docker run -p 80:80 -p 443:443 -p 3000:3000 \\" log " -e ACCEPTED_TERMS=true -v /captain:/captain \\" log " caprover/caprover-edge" log "" log "3. Extract the bundle:" log " mkdir -p /backups/restore && cd /backups/restore" log " tar xzf caprover-migration-${DATE}.tar.gz" log "" log "4. Stop CapRover on new server:" log " docker service rm captain-captain --force" log "" log "5. Restore /captain config:" log " tar xzf captain-config-${DATE}.tar.gz -C /" log "" log "6. Restore Docker volumes:" log " for f in vol-*-${DATE}.tar.gz; do" log ' vol="captain--${f#vol-}"' log ' vol="${vol%-'"${DATE}"'.tar.gz}"' log " docker volume create \"\$vol\"" log " docker run --rm -v \"\${vol}:/dest\" -v \"\$(pwd):/backup:ro\" \\" log " alpine sh -c \"tar xzf /backup/\$f -C /dest\"" log " done" log "" log "7. Start CapRover and re-deploy apps:" log " docker run -p 80:80 -p 443:443 -p 3000:3000 \\" log " -e ACCEPTED_TERMS=true -v /captain:/captain \\" log " caprover/caprover-edge" log "" log "8. Update DNS to point to the new server IP." log "=========================================" } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { if $MIGRATE; then log "=========================================" log "CapRover MIGRATION backup — ${DATE}" log "Mode: full migration (containers will be stopped)" log "=========================================" preflight local errors=0 export_app_definitions || { ((errors++)) || true; } save_service_state || { ((errors++)) || true; } stop_captain_containers backup_captain_config || { ((errors++)) || true; } backup_volumes || { ((errors++)) || true; } create_migration_bundle if [ "$errors" -gt 0 ]; then log "Migration backup completed with $errors error(s)." exit 1 else log "Migration backup completed successfully." log "Containers remain stopped. This server is ready to decommission." fi else log "=========================================" log "CapRover backup started — ${DATE}" log "Destination: ${BACKUP_DEST}" log "=========================================" preflight local errors=0 backup_captain_config || { ((errors++)) || true; } backup_volumes || { ((errors++)) || true; } export_app_definitions || { ((errors++)) || true; } upload_remote || { ((errors++)) || true; } apply_retention if [ "$errors" -gt 0 ]; then log "Backup completed with $errors error(s)." exit 1 else log "Backup completed successfully." fi fi } main "$@"