#!/bin/bash # ============================================================================= # LetsBe Restore Script # ============================================================================= # # Restores backups created by backups.sh. # # Usage: # restore.sh list List available local backups # restore.sh list-remote List available remote backups # restore.sh download Download a remote backup set locally # restore.sh postgres Restore a PostgreSQL database # restore.sh mysql Restore a MySQL/MariaDB database # restore.sh mongo Restore a MongoDB database # restore.sh env Restore env files # restore.sh configs Restore config files # restore.sh nginx Restore nginx configs # restore.sh full Full restore from a backup date # # Examples: # restore.sh list # restore.sh postgres chatwoot /tmp/letsbe-backups/pg_chatwoot_20260207_020000.sql.gz # restore.sh env /tmp/letsbe-backups/dir_env-files_20260207_020000.tar.gz # restore.sh download 20260207_020000 # restore.sh full 20260207_020000 # # IMPORTANT: # - Always stop the tool's application containers before restoring its database. # - Database containers must remain running during restore. # - After restore, restart the full tool stack. # # ============================================================================= set -uo pipefail LETSBE_BASE="/opt/letsbe" BACKUP_DIR="/tmp/letsbe-backups" RCLONE_REMOTE="backup" # ============================================================================= # HELPERS # ============================================================================= log() { echo "[RESTORE] $*" } die() { echo "[RESTORE ERROR] $*" >&2 exit 1 } require_file() { local file=$1 [[ -f "$file" ]] || die "File not found: $file" } # Find a running container by suffix pattern find_container() { local pattern=$1 docker ps --format '{{.Names}}' | grep -E "(^|-)${pattern}$" | head -1 } # ============================================================================= # COMMANDS # ============================================================================= cmd_list() { log "Available local backups in ${BACKUP_DIR}:" echo "" if [[ -d "$BACKUP_DIR" ]]; then ls -lhS "$BACKUP_DIR"/ 2>/dev/null || echo " (empty)" else echo " No backup directory found." fi } cmd_list_remote() { if ! command -v rclone &> /dev/null; then die "rclone not installed" fi if ! rclone listremotes 2>/dev/null | grep -q "^${RCLONE_REMOTE}:"; then die "rclone remote '${RCLONE_REMOTE}' not configured" fi log "Available remote backups:" echo "" echo "Daily:" rclone lsd "${RCLONE_REMOTE}:letsbe-backups/" 2>/dev/null | grep -v "weekly" | awk '{print " " $NF}' echo "" echo "Weekly:" rclone lsd "${RCLONE_REMOTE}:letsbe-backups/weekly/" 2>/dev/null | awk '{print " " $NF}' } cmd_download() { local date_str=$1 if ! command -v rclone &> /dev/null; then die "rclone not installed" fi local remote_path="${RCLONE_REMOTE}:letsbe-backups/${date_str}/" log "Downloading backup from ${remote_path}..." mkdir -p "$BACKUP_DIR" rclone copy "$remote_path" "$BACKUP_DIR/" --progress log "Download complete. Files in ${BACKUP_DIR}/" } cmd_restore_postgres() { local tool=$1 local file=$2 require_file "$file" # Map tool name to container suffix, db name, and user local container db_name db_user case "$tool" in chatwoot) container="chatwoot-postgres"; db_name="chatwoot_production"; db_user="chatwoot" ;; nextcloud) container="nextcloud-postgres"; db_name="nextcloud"; db_user="nextcloud" ;; keycloak) container="keycloak-db"; db_name="keycloak"; db_user="keycloak" ;; n8n) container="n8n-postgres"; db_name="n8n"; db_user="postgres" ;; calcom) container="calcom-postgres"; db_name="calcom"; db_user="postgres" ;; umami) container="umami-db"; db_name="umami"; db_user="postgres" ;; nocodb) container="nocodb-postgres"; db_name="nocodb"; db_user="postgres" ;; typebot) container="typebot-db"; db_name="typebot"; db_user="postgres" ;; windmill) container="windmill-db"; db_name="windmill"; db_user="postgres" ;; glitchtip) container="glitchtip-postgres"; db_name="postgres"; db_user="postgres" ;; penpot) container="penpot-postgres"; db_name="penpot"; db_user="postgres" ;; gitea) container="gitea-db"; db_name="gitea"; db_user="postgres" ;; odoo) container="odoo-postgres"; db_name="postgres"; db_user="postgres" ;; listmonk) container="listmonk-db"; db_name="listmonk"; db_user="postgres" ;; documenso) container="documenso-db"; db_name="documenso_db"; db_user="postgres" ;; redash) container="redash-postgres"; db_name="postgres"; db_user="postgres" ;; activepieces) container="activepieces-postgres"; db_name="activepieces"; db_user="postgres" ;; orchestrator) container="orchestrator-db"; db_name="orchestrator"; db_user="orchestrator" ;; *) die "Unknown PostgreSQL tool: $tool. Use one of: chatwoot, nextcloud, keycloak, n8n, calcom, umami, nocodb, typebot, windmill, glitchtip, penpot, gitea, odoo, listmonk, documenso, redash, activepieces, orchestrator" ;; esac local actual_container actual_container=$(find_container "$container") [[ -z "$actual_container" ]] && die "Container matching '$container' not found. Is it running?" log "Restoring PostgreSQL: $tool" log " Container: $actual_container" log " Database: $db_name" log " File: $file" echo "" read -p "WARNING: This will DROP and recreate database '$db_name'. Continue? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Restore cancelled." log "Dropping and recreating database..." docker exec "$actual_container" psql -U "$db_user" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" postgres 2>/dev/null || true docker exec "$actual_container" psql -U "$db_user" -c "DROP DATABASE IF EXISTS \"$db_name\";" postgres docker exec "$actual_container" psql -U "$db_user" -c "CREATE DATABASE \"$db_name\";" postgres log "Restoring from backup..." if [[ "$file" == *.gz ]]; then gunzip -c "$file" | docker exec -i "$actual_container" psql -U "$db_user" "$db_name" else docker exec -i "$actual_container" psql -U "$db_user" "$db_name" < "$file" fi log "PostgreSQL restore complete for $tool." log "Restart the $tool application containers to reconnect." } cmd_restore_mysql() { local tool=$1 local file=$2 require_file "$file" local container db_name db_user case "$tool" in wordpress) container="wordpress-mysql"; db_name="wordpress"; db_user="root" ;; ghost) container="ghost-db"; db_name="ghost"; db_user="root" ;; *) die "Unknown MySQL tool: $tool. Use one of: wordpress, ghost" ;; esac local actual_container actual_container=$(find_container "$container") [[ -z "$actual_container" ]] && die "Container matching '$container' not found. Is it running?" log "Restoring MySQL: $tool" log " Container: $actual_container" log " Database: $db_name" log " File: $file" echo "" read -p "WARNING: This will overwrite database '$db_name'. Continue? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Restore cancelled." log "Restoring from backup..." # Read root password from credentials local creds_file="${LETSBE_BASE}/env/credentials.env" local db_pass="" if [[ "$tool" == "wordpress" ]]; then db_pass=$(grep "^WORDPRESS_MARIADB_ROOT_PASSWORD=" "$creds_file" 2>/dev/null | cut -d'=' -f2-) elif [[ "$tool" == "ghost" ]]; then db_pass=$(grep "^GHOST_MYSQL_PASSWORD=" "$creds_file" 2>/dev/null | cut -d'=' -f2-) fi [[ -z "$db_pass" ]] && die "Could not read database password from $creds_file" if [[ "$file" == *.gz ]]; then gunzip -c "$file" | docker exec -i "$actual_container" mysql -u"$db_user" -p"$db_pass" "$db_name" else docker exec -i "$actual_container" mysql -u"$db_user" -p"$db_pass" "$db_name" < "$file" fi log "MySQL restore complete for $tool." log "Restart the $tool application containers to reconnect." } cmd_restore_mongo() { local tool=$1 local file=$2 require_file "$file" local container db_name case "$tool" in librechat) container="librechat-mongodb"; db_name="LibreChat" ;; *) die "Unknown MongoDB tool: $tool. Use: librechat" ;; esac local actual_container actual_container=$(find_container "$container") [[ -z "$actual_container" ]] && die "Container matching '$container' not found. Is it running?" log "Restoring MongoDB: $tool" log " Container: $actual_container" log " Database: $db_name" log " File: $file" echo "" read -p "WARNING: This will drop and restore database '$db_name'. Continue? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Restore cancelled." log "Restoring from backup..." if [[ "$file" == *.gz ]]; then gunzip -c "$file" | docker exec -i "$actual_container" mongorestore --db "$db_name" --drop --archive else docker exec -i "$actual_container" mongorestore --db "$db_name" --drop --archive < "$file" fi log "MongoDB restore complete for $tool." log "Restart the $tool application containers to reconnect." } cmd_restore_env() { local file=$1 require_file "$file" log "Restoring env files from: $file" echo "" read -p "WARNING: This will overwrite files in ${LETSBE_BASE}/env/. Continue? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Restore cancelled." # Backup current env files first local timestamp timestamp=$(date +%Y%m%d_%H%M%S) if [[ -d "${LETSBE_BASE}/env" ]]; then log "Backing up current env files to ${LETSBE_BASE}/env.pre-restore.${timestamp}..." cp -a "${LETSBE_BASE}/env" "${LETSBE_BASE}/env.pre-restore.${timestamp}" fi log "Extracting..." tar xzf "$file" -C "${LETSBE_BASE}/" chmod 600 "${LETSBE_BASE}/env/"*.env 2>/dev/null || true log "Env files restored." } cmd_restore_configs() { local file=$1 require_file "$file" log "Restoring config files from: $file" echo "" read -p "WARNING: This will overwrite files in ${LETSBE_BASE}/config/. Continue? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Restore cancelled." local timestamp timestamp=$(date +%Y%m%d_%H%M%S) if [[ -d "${LETSBE_BASE}/config" ]]; then cp -a "${LETSBE_BASE}/config" "${LETSBE_BASE}/config.pre-restore.${timestamp}" fi tar xzf "$file" -C "${LETSBE_BASE}/" log "Config files restored." } cmd_restore_nginx() { local file=$1 require_file "$file" log "Restoring nginx configs from: $file" echo "" read -p "WARNING: This will overwrite files in ${LETSBE_BASE}/nginx/. Continue? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Restore cancelled." local timestamp timestamp=$(date +%Y%m%d_%H%M%S) if [[ -d "${LETSBE_BASE}/nginx" ]]; then cp -a "${LETSBE_BASE}/nginx" "${LETSBE_BASE}/nginx.pre-restore.${timestamp}" fi tar xzf "$file" -C "${LETSBE_BASE}/" log "Nginx configs restored." log "Run: systemctl restart nginx" } cmd_full_restore() { local date_str=$1 local backup_path="$BACKUP_DIR" log "=== Full System Restore for date: $date_str ===" echo "" echo "This will restore ALL databases and configuration files from the backup." echo "Make sure all tool containers are stopped (except database containers)." echo "" read -p "Continue with full restore? (yes/no): " confirm [[ "$confirm" == "yes" ]] || die "Full restore cancelled." # Check if files exist locally, download if not local pg_count pg_count=$(ls "${backup_path}"/pg_*"${date_str}"* 2>/dev/null | wc -l) if [[ "$pg_count" -eq 0 ]]; then log "Backup files not found locally. Attempting remote download..." cmd_download "$date_str" fi # Restore env files local env_file="${backup_path}/dir_env-files_${date_str}.tar.gz" if [[ -f "$env_file" ]]; then log "Restoring env files..." # Non-interactive for full restore (already confirmed above) local timestamp timestamp=$(date +%Y%m%d_%H%M%S) [[ -d "${LETSBE_BASE}/env" ]] && cp -a "${LETSBE_BASE}/env" "${LETSBE_BASE}/env.pre-restore.${timestamp}" tar xzf "$env_file" -C "${LETSBE_BASE}/" chmod 600 "${LETSBE_BASE}/env/"*.env 2>/dev/null || true log " Env files restored." fi # Restore configs local cfg_file="${backup_path}/dir_letsbe-config_${date_str}.tar.gz" if [[ -f "$cfg_file" ]]; then log "Restoring config files..." tar xzf "$cfg_file" -C "${LETSBE_BASE}/" log " Config files restored." fi # Restore nginx configs local nginx_file="${backup_path}/dir_nginx-configs_${date_str}.tar.gz" if [[ -f "$nginx_file" ]]; then log "Restoring nginx configs..." tar xzf "$nginx_file" -C "${LETSBE_BASE}/" log " Nginx configs restored." fi # Restore all PostgreSQL databases found for this date log "Restoring PostgreSQL databases..." for pg_file in "${backup_path}"/pg_*"${date_str}"*.sql.gz; do [[ -f "$pg_file" ]] || continue # Extract tool name from filename: pg__.sql.gz local tool_name tool_name=$(basename "$pg_file" | sed "s/^pg_//;s/_${date_str}.*//") log " Restoring PostgreSQL: $tool_name" # Find container and restore without interactive prompt local container db_name db_user case "$tool_name" in chatwoot) container="chatwoot-postgres"; db_name="chatwoot_production"; db_user="chatwoot" ;; nextcloud) container="nextcloud-postgres"; db_name="nextcloud"; db_user="nextcloud" ;; keycloak) container="keycloak-db"; db_name="keycloak"; db_user="keycloak" ;; n8n) container="n8n-postgres"; db_name="n8n"; db_user="postgres" ;; calcom) container="calcom-postgres"; db_name="calcom"; db_user="postgres" ;; umami) container="umami-db"; db_name="umami"; db_user="postgres" ;; nocodb) container="nocodb-postgres"; db_name="nocodb"; db_user="postgres" ;; typebot) container="typebot-db"; db_name="typebot"; db_user="postgres" ;; windmill) container="windmill-db"; db_name="windmill"; db_user="postgres" ;; glitchtip) container="glitchtip-postgres"; db_name="postgres"; db_user="postgres" ;; penpot) container="penpot-postgres"; db_name="penpot"; db_user="postgres" ;; gitea) container="gitea-db"; db_name="gitea"; db_user="postgres" ;; odoo) container="odoo-postgres"; db_name="postgres"; db_user="postgres" ;; listmonk) container="listmonk-db"; db_name="listmonk"; db_user="postgres" ;; documenso) container="documenso-db"; db_name="documenso_db"; db_user="postgres" ;; redash) container="redash-postgres"; db_name="postgres"; db_user="postgres" ;; activepieces) container="activepieces-postgres"; db_name="activepieces"; db_user="postgres" ;; orchestrator) container="orchestrator-db"; db_name="orchestrator"; db_user="orchestrator" ;; *) log " Skipping unknown tool: $tool_name"; continue ;; esac local actual_container actual_container=$(find_container "$container") if [[ -z "$actual_container" ]]; then log " WARNING: Container '$container' not running, skipping $tool_name" continue fi docker exec "$actual_container" psql -U "$db_user" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" postgres 2>/dev/null || true docker exec "$actual_container" psql -U "$db_user" -c "DROP DATABASE IF EXISTS \"$db_name\";" postgres 2>/dev/null || true docker exec "$actual_container" psql -U "$db_user" -c "CREATE DATABASE \"$db_name\";" postgres 2>/dev/null || true gunzip -c "$pg_file" | docker exec -i "$actual_container" psql -U "$db_user" "$db_name" > /dev/null 2>&1 log " OK: $tool_name" done # Restore MySQL databases log "Restoring MySQL databases..." for mysql_file in "${backup_path}"/mysql_*"${date_str}"*.sql.gz; do [[ -f "$mysql_file" ]] || continue local tool_name tool_name=$(basename "$mysql_file" | sed "s/^mysql_//;s/_${date_str}.*//") log " Restoring MySQL: $tool_name" local container db_name db_pass case "$tool_name" in wordpress) container="wordpress-mysql"; db_name="wordpress" db_pass=$(grep "^WORDPRESS_MARIADB_ROOT_PASSWORD=" "${LETSBE_BASE}/env/credentials.env" 2>/dev/null | cut -d'=' -f2-) ;; ghost) container="ghost-db"; db_name="ghost" db_pass=$(grep "^GHOST_MYSQL_PASSWORD=" "${LETSBE_BASE}/env/credentials.env" 2>/dev/null | cut -d'=' -f2-) ;; *) log " Skipping unknown MySQL tool: $tool_name"; continue ;; esac local actual_container actual_container=$(find_container "$container") if [[ -z "$actual_container" ]]; then log " WARNING: Container '$container' not running, skipping $tool_name" continue fi if [[ -n "$db_pass" ]]; then gunzip -c "$mysql_file" | docker exec -i "$actual_container" mysql -uroot -p"$db_pass" "$db_name" 2>/dev/null log " OK: $tool_name" else log " SKIP: No password found for $tool_name" fi done # Restore MongoDB databases log "Restoring MongoDB databases..." for mongo_file in "${backup_path}"/mongo_*"${date_str}"*.archive.gz; do [[ -f "$mongo_file" ]] || continue local tool_name tool_name=$(basename "$mongo_file" | sed "s/^mongo_//;s/_${date_str}.*//") log " Restoring MongoDB: $tool_name" case "$tool_name" in librechat) local actual_container actual_container=$(find_container "librechat-mongodb") if [[ -n "$actual_container" ]]; then gunzip -c "$mongo_file" | docker exec -i "$actual_container" mongorestore --db LibreChat --drop --archive 2>/dev/null log " OK: $tool_name" else log " WARNING: Container not running, skipping" fi ;; *) log " Skipping unknown MongoDB tool: $tool_name" ;; esac done log "" log "=== Full Restore Complete ===" log "Now restart all tool stacks:" log " for stack in ${LETSBE_BASE}/stacks/*/docker-compose.yml; do" log " docker-compose -f \"\$stack\" restart" log " done" log " systemctl restart nginx" } # ============================================================================= # MAIN # ============================================================================= if [[ $# -lt 1 ]]; then echo "LetsBe Restore Tool" echo "" echo "Usage:" echo " $0 list List local backups" echo " $0 list-remote List remote backups" echo " $0 download Download remote backup" echo " $0 postgres Restore PostgreSQL database" echo " $0 mysql Restore MySQL database" echo " $0 mongo Restore MongoDB database" echo " $0 env Restore env files" echo " $0 configs Restore config files" echo " $0 nginx Restore nginx configs" echo " $0 full Full system restore" echo "" echo "PostgreSQL tools: chatwoot, nextcloud, keycloak, n8n, calcom, umami," echo " nocodb, typebot, windmill, glitchtip, penpot, gitea, odoo, listmonk," echo " documenso, redash, activepieces, orchestrator" echo "" echo "MySQL tools: wordpress, ghost" echo "" echo "MongoDB tools: librechat" exit 1 fi case "$1" in list) cmd_list ;; list-remote) cmd_list_remote ;; download) [[ $# -ge 2 ]] || die "Usage: $0 download "; cmd_download "$2" ;; postgres) [[ $# -ge 3 ]] || die "Usage: $0 postgres "; cmd_restore_postgres "$2" "$3" ;; mysql) [[ $# -ge 3 ]] || die "Usage: $0 mysql "; cmd_restore_mysql "$2" "$3" ;; mongo) [[ $# -ge 3 ]] || die "Usage: $0 mongo "; cmd_restore_mongo "$2" "$3" ;; env) [[ $# -ge 2 ]] || die "Usage: $0 env "; cmd_restore_env "$2" ;; configs) [[ $# -ge 2 ]] || die "Usage: $0 configs "; cmd_restore_configs "$2" ;; nginx) [[ $# -ge 2 ]] || die "Usage: $0 nginx "; cmd_restore_nginx "$2" ;; full) [[ $# -ge 2 ]] || die "Usage: $0 full "; cmd_full_restore "$2" ;; *) die "Unknown command: $1. Run '$0' for usage." ;; esac