#!/usr/bin/env bash set -euo pipefail # Constants readonly GHOST_UID=1000 readonly GHOST_GID=1000 readonly MYSQL_TIMEOUT=120 readonly DISK_SPACE_SAFETY_FACTOR=1.5 readonly TEMP_SQL_FILE="${PWD}/data/ghost_import.sql" readonly RECOVERY_SCRIPT="${PWD}/recovery_instructions.sh" # Global variables current_location="" mysql_user="" mysql_password="" ghost_service_name="" # Function to convert bytes to human readable format human_readable() { local bytes=$1 local units=("B" "KB" "MB" "GB" "TB") local unit=0 local size=$bytes while (( $(echo "$size > 1024" | bc -l) )) && (( unit < 4 )); do size=$(echo "scale=2; $size / 1024" | bc -l) ((unit++)) done echo "${size} ${units[$unit]}" } # Function to get size in bytes get_size_bytes() { local path=$1 if [[ -d "$path" ]]; then du -sb "$path" 2>/dev/null | cut -f1 else echo "0" fi } # Cleanup function cleanup() { local exit_code=$? if [[ -f "$TEMP_SQL_FILE" ]]; then echo "Cleaning up temporary files..." rm "$TEMP_SQL_FILE" fi if [[ $exit_code -ne 0 && -f "$RECOVERY_SCRIPT" ]]; then echo "" echo "════════════════════════════════════════════════════════════" echo "❌ MIGRATION FAILED!" echo "════════════════════════════════════════════════════════════" echo "" echo "Don't worry - your data is safe!" echo "" echo "To restore your original Ghost installation, run:" echo " bash $RECOVERY_SCRIPT" echo "" echo "Need help? Check the migration logs above for error details." echo "════════════════════════════════════════════════════════════" fi exit $exit_code } # Set trap for cleanup trap cleanup EXIT INT TERM # Create recovery script create_recovery_script() { local service_name="$1" cat > "$RECOVERY_SCRIPT" << EOF #!/usr/bin/env bash # Recovery script generated by Ghost migration on $(date) # This script will restore your original Ghost installation set -euo pipefail echo "Restoring original Ghost installation..." # Stop any Docker containers that might have been started docker compose down 2>/dev/null || true # Re-enable and start the original Ghost service if [[ -n "${service_name}" ]]; then systemctl enable "${service_name}" 2>/dev/null || true systemctl start "${service_name}" 2>/dev/null || true echo "Original Ghost installation has been restored." echo "You can check the status with: systemctl status ${service_name}" else echo "Note: Ghost service was not yet stopped, so no restoration needed." echo "Your original installation should still be running." fi EOF chmod +x "$RECOVERY_SCRIPT" echo "✓ Recovery script created at: $RECOVERY_SCRIPT" } # Validate MySQL connection validate_mysql_connection() { local host=$1 local database=$2 local user=$3 local password=$4 echo "Testing MySQL connection..." if mysql -h"$host" -u"$user" -p"$password" -e "SELECT 1 FROM information_schema.tables WHERE table_schema='$database' LIMIT 1;" &>/dev/null; then echo "✓ MySQL connection successful" return 0 else echo "✗ MySQL connection failed" return 1 fi } # Check prerequisites check_prerequisites() { # Check we're running as root if [[ "$EUID" -ne 0 ]]; then echo "Sorry, this script must be run as root!" exit 1 fi # Check required commands local required_commands=("jq" "docker" "bc" "mysql" "mysqldump" "rsync") local missing_commands=() for cmd in "${required_commands[@]}"; do if ! command -v "$cmd" &>/dev/null; then missing_commands+=("$cmd") fi done if [[ ${#missing_commands[@]} -gt 0 ]]; then echo "The following required commands are not installed:" printf ' - %s\n' "${missing_commands[@]}" echo "Please install them first." exit 1 fi } # Show migration summary show_migration_summary() { echo " ═══════════════════════════════════════════════════════════════════ GHOST MIGRATION SUMMARY ═══════════════════════════════════════════════════════════════════ This script will migrate your Ghost CLI installation to Docker. WHAT WILL HAPPEN: ✓ Validate MySQL credentials ✓ Stop your current Ghost installation ✓ Copy content directory to Docker mount ✓ Export and import your database to a Docker based MySQL instance ✓ Start Ghost in Docker container ✓ Optionally configure Caddy Webserver for HTTPS WHAT WONT HAPPEN: ✓ No data will be deleted ✓ Recovery script will be created ✓ Original installation remains intact REQUIREMENTS: ✓ .env file configured for Docker ✓ MySQL credentials with dump permissions ✓ Sufficient disk space for migration ═══════════════════════════════════════════════════════════════════ " } # Migrate content directory migrate_content() { local source="${current_location}/content/" local dest="${PWD}/data/ghost/" echo "Starting content migration..." echo "Source: $source" echo "Destination: $dest" echo "" # Create destination directory mkdir -p "$dest" # Copy with progress echo "Copying files..." rsync --info=progress2 -aH "$source" "$dest" echo "" echo "Setting permissions for Ghost container (UID: $GHOST_UID, GID: $GHOST_GID)..." chown -R ${GHOST_UID}:${GHOST_GID} "$dest" echo "✓ Content migration completed" } # Test if we can dump database with given credentials test_mysql_dump() { local host=$1 local database=$2 local user=$3 local password=$4 # Try a minimal dump to test permissions if MYSQL_PWD="$password" mysqldump --no-tablespaces --no-data -h"$host" -u"$user" "$database" >/dev/null 2>&1; then return 0 else return 1 fi } # Export and import database migrate_database() { local mysql_host local mysql_database mysql_host=$(jq -r < "${current_location}/config.production.json" '.database.connection.host') mysql_database=$(jq -r < "${current_location}/config.production.json" '.database.connection.database') echo "Exporting database from $mysql_host..." # Export database with proper error handling local dump_output local dump_status dump_output=$(MYSQL_PWD="$mysql_password" mysqldump --no-tablespaces -h"$mysql_host" -u"$mysql_user" "$mysql_database" 2>&1 > "$TEMP_SQL_FILE") dump_status=$? # Check for errors in output (mysqldump may return 0 even with some errors) if [[ $dump_status -ne 0 ]] || [[ "$dump_output" =~ "Error:" ]]; then echo "" echo "ERROR: Failed to export database" if [[ "$dump_output" =~ "PROCESS privilege" ]]; then echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "The MySQL user '$mysql_user' needs the PROCESS privilege." echo "" echo "To fix this, connect to MySQL as a privileged user and run:" echo " GRANT PROCESS ON *.* TO '$mysql_user'@'%';" echo " FLUSH PRIVILEGES;" echo "" echo "Or retry with a user that has sufficient privileges (e.g., root)." echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" elif [[ -n "$dump_output" ]]; then echo "Error details: $dump_output" fi exit 1 fi # Verify the dump file exists and has content if [[ ! -f "$TEMP_SQL_FILE" ]] || [[ ! -s "$TEMP_SQL_FILE" ]]; then echo "" echo "ERROR: Database dump file is empty or missing" echo "This might indicate insufficient disk space or permissions issues." exit 1 fi local dump_size dump_size=$(human_readable "$(stat -c%s "$TEMP_SQL_FILE")") echo "✓ Database exported successfully ($dump_size)" # Start MySQL container echo "Starting MySQL container..." docker compose up db -d # Wait for MySQL to be ready echo -n "Waiting for MySQL container to be ready" local counter=0 until [ "$(docker compose ps db --format json | jq -r '.Health')" = "healthy" ] || [ $counter -eq $MYSQL_TIMEOUT ]; do echo -n "." sleep 1 ((counter++)) || true done if [[ $counter -eq $MYSQL_TIMEOUT ]]; then echo "" echo "ERROR: Timed out waiting for MySQL container" exit 1 fi echo " ✓" # Import database echo "Importing database into Docker MySQL..." local MYSQL_ROOT_PASSWORD MYSQL_ROOT_PASSWORD=$(grep DATABASE_ROOT_PASSWORD "$PWD/.env" | cut -d '=' -f 2-) if ! docker compose exec -e MYSQL_PWD="$MYSQL_ROOT_PASSWORD" -T db sh -c 'exec mysql -uroot $MYSQL_DATABASE' < "$TEMP_SQL_FILE"; then echo "ERROR: Failed to import database" exit 1 fi echo "✓ Database migration completed" # Clean up SQL file rm -f "$TEMP_SQL_FILE" } # Find Ghost installations in /var/www/ find_ghost_installations() { local installations=() # Search one level deep in /var/www/ if [[ -d "/var/www" ]]; then for dir in /var/www/*/; do # Skip if not a directory [[ ! -d "$dir" ]] && continue # Remove trailing slash dir="${dir%/}" # Check if it's a valid Ghost installation if [[ -f "${dir}/.ghost-cli" ]] && [[ -d "${dir}/content" ]]; then # Additional validation - check if config file exists if [[ -f "${dir}/config.production.json" ]]; then installations+=("$dir") fi fi done fi printf '%s\n' "${installations[@]}" } # Main script starts here main() { check_prerequisites echo "WARNING: This script is currently in beta, please ensure you have a backup!" show_migration_summary read -rp 'Ready to proceed with migration? (y/n): ' confirm if [[ "${confirm,,}" != "y" ]]; then echo "Migration cancelled." exit 0 fi # Search for Ghost installations echo "" echo "Searching for Ghost installations in /var/www/..." local ghost_installations=() while IFS= read -r line; do [[ -n "$line" ]] && ghost_installations+=("$line") done < <(find_ghost_installations) # Get installation location if [[ ${#ghost_installations[@]} -gt 0 ]]; then echo "" echo "Found ${#ghost_installations[@]} Ghost installation(s) in /var/www/:" echo "" # Display found installations with numbers local i=1 for installation in "${ghost_installations[@]}"; do local site_name site_name=$(basename "$installation") echo " $i) $site_name (${installation})" ((i++)) done echo " $i) Enter a different path" echo "" read -rp "Select an installation (1-$i): " selection # Validate selection if [[ "$selection" =~ ^[0-9]+$ ]] && [[ $selection -ge 1 ]] && [[ $selection -le $i ]]; then if [[ $selection -eq $i ]]; then # User wants to enter a different path read -rp 'Enter your current Ghost installation path: ' current_location else # User selected a found installation current_location="${ghost_installations[$((selection-1))]}" echo "Selected: $current_location" fi else echo "Invalid selection" exit 1 fi else # No installations found, ask for path directly echo "" echo "No Ghost installations found in /var/www/" read -rp 'Enter your current Ghost installation path: ' current_location fi if [[ -z "$current_location" ]]; then echo "ERROR: Installation path is required" exit 1 fi # Validate Ghost installation if [[ ! -f "${current_location}/.ghost-cli" ]]; then echo "ERROR: No Ghost-CLI installation found at ${current_location}" exit 1 fi if [[ ! -d "${current_location}/content" ]]; then echo "ERROR: No content directory found at ${current_location}/content" exit 1 fi # Check for .env file if [[ ! -f "${PWD}/.env" ]]; then echo "ERROR: Please create a .env file for the Docker installation first" exit 1 fi # Get Ghost service name ghost_service_name="ghost_$(jq -r < "${current_location}/.ghost-cli" '.name')" # Get database configuration local mysql_host local mysql_database local ghost_mysql_user local ghost_mysql_password mysql_host=$(jq -r < "${current_location}/config.production.json" '.database.connection.host') mysql_database=$(jq -r < "${current_location}/config.production.json" '.database.connection.database') ghost_mysql_user=$(jq -r < "${current_location}/config.production.json" '.database.connection.user') ghost_mysql_password=$(jq -r < "${current_location}/config.production.json" '.database.connection.password') # Check disk space echo "" echo "Checking disk space requirements..." local content_size local content_size_human local required_space local required_space_human local available_space local available_space_human content_size=$(get_size_bytes "${current_location}/content") content_size_human=$(human_readable "$content_size") required_space=$(echo "$content_size * $DISK_SPACE_SAFETY_FACTOR" | bc | cut -d'.' -f1) required_space_human=$(human_readable "$required_space") available_space=$(df -B1 "${PWD}" | tail -1 | awk '{print $4}') available_space_human=$(human_readable "$available_space") echo " Content size: ${content_size_human}" echo " Required space: ${required_space_human}" echo " Available space: ${available_space_human}" if (( available_space < required_space )); then echo "" echo "ERROR: Insufficient disk space!" echo "Need ${required_space_human} but only ${available_space_human} available." exit 1 fi echo "✓ Disk space check passed" echo "" # Try Ghost's own credentials first echo "Testing database export with Ghost's credentials..." if test_mysql_dump "$mysql_host" "$mysql_database" "$ghost_mysql_user" "$ghost_mysql_password"; then echo "✓ Ghost's credentials have sufficient privileges" mysql_user="$ghost_mysql_user" mysql_password="$ghost_mysql_password" else echo "Ghost's database user doesn't have sufficient privileges for export." echo "Please provide credentials for a MySQL user with dump privileges." echo "" # Get MySQL credentials and validate read -rp "MySQL user for database export (default: root): " mysql_user mysql_user=${mysql_user:-root} # Get password securely echo -n "MySQL password for ${mysql_user}: " read -rs mysql_password echo "" # Validate connection if ! validate_mysql_connection "$mysql_host" "$mysql_database" "$mysql_user" "$mysql_password"; then echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Could not connect to MySQL database." echo "" echo "Please verify:" echo " • MySQL service is running" echo " • Credentials are correct" echo " • User has access from this host" echo " • Database name is correct" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 1 fi # Test dump permissions if ! test_mysql_dump "$mysql_host" "$mysql_database" "$mysql_user" "$mysql_password"; then echo "" echo "ERROR: The provided user doesn't have sufficient privileges for database export." echo "Please ensure the user has the necessary privileges or try a different user." exit 1 fi fi # Create recovery script (with empty service name since we haven't stopped anything yet) create_recovery_script "" # Final confirmation before stopping Ghost echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "⚠️ YOUR SITE WILL BE UNAVAILABLE DURING MIGRATION" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "The next steps will:" echo " 1. Stop your Ghost service" echo " 2. Migrate your content and database" echo " a. Your new content directory will be at ${PWD}/data/ghost/" echo " b. Your new MySQL database will be at ${PWD}/data/mysql/" echo " 3. Start Ghost in Docker" echo "" echo "If anything goes wrong, run: bash $RECOVERY_SCRIPT" echo "" read -rp 'Continue with migration? This will make your site unavailable. (y/n): ' confirm if [[ "${confirm,,}" != "y" ]]; then echo "Migration cancelled." rm -f "$RECOVERY_SCRIPT" exit 0 fi # Stop Ghost service echo "" echo "Stopping Ghost service..." # Update recovery script with actual service name before stopping create_recovery_script "$ghost_service_name" if ! systemctl stop "$ghost_service_name"; then echo "ERROR: Failed to stop Ghost service" echo "Please check: systemctl status $ghost_service_name" exit 1 fi systemctl disable "$ghost_service_name" 2>/dev/null || true echo "✓ Ghost service stopped" # Migrate content echo "" migrate_content # Migrate database echo "" migrate_database # Import configuration echo "" echo "Importing configuration from existing installation..." echo "" node "${PWD}/scripts/config-to-env.js" "${current_location}/config.production.json" echo "" echo -e "\n# Configuration imported from existing Ghost install at ${current_location}" >> "${PWD}/.env" node "${PWD}/scripts/config-to-env.js" "${current_location}/config.production.json" >> "${PWD}/.env" echo "✓ Configuration imported" # Start Ghost echo "" echo "Starting Ghost container..." docker compose up ghost -d echo "✓ Ghost is running in Docker" # Caddy setup echo "" read -rp 'Start Caddy Webserver for automatic HTTPS? This will stop Nginx. (y/n): ' confirm if [[ "${confirm,,}" == "y" ]]; then echo "Stopping Nginx..." systemctl stop nginx -q || true systemctl disable nginx -q || true echo "Starting Caddy..." docker compose up caddy -d local domain domain=$(grep 'DOMAIN' "${PWD}/.env" | cut -d '=' -f 2-) echo "" echo "✓ Caddy Webserver is running!" echo "✓ Your site is available at: https://${domain}" else local ghost_port ghost_port=$(grep 'GHOST_PORT' "${PWD}/.env" | cut -d '=' -f 2-) echo "" echo "✓ Ghost is now running" echo " To finish migration, configure your webserver to forward traffic to 127.0.0.1:${ghost_port}" fi # Success! Remove recovery script rm -f "$RECOVERY_SCRIPT" echo "" echo "════════════════════════════════════════════════════════════" echo "✓ MIGRATION COMPLETED SUCCESSFULLY!" echo "════════════════════════════════════════════════════════════" echo "" echo "Your Ghost site is now running in Docker." echo "Original installation files remain at: $current_location" echo "Your existing MySQL instance is still running at: $mysql_host" echo "" echo "Once you're checked over the migration you can remove the old installation files and database by running:" echo "" echo " rm -r $current_location/" echo " mysql -e 'DROP DATABASE IF EXISTS ${mysql_database}'" echo "" echo "This will remove the old Ghost CLI and Ghost 5.x installation" echo "" echo "Next steps:" echo " - Monitor logs: docker compose logs -f ghost" echo " - View status: docker compose ps" echo " - Stop services: docker compose down" echo "" } # Run main function main "$@"