#!/bin/bash # Production Deployment Script for Custom PHP Framework # Comprehensive deployment automation with zero-downtime strategy # # Usage: # ./scripts/production-deploy.sh [initial|update|rollback] # # Modes: # initial - First-time production deployment # update - Rolling update with zero downtime # rollback - Rollback to previous version set -euo pipefail # Configuration DEPLOY_MODE="${1:-update}" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BACKUP_DIR="${PROJECT_ROOT}/../backups" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_PATH="${BACKUP_DIR}/backup_${TIMESTAMP}" # Colors GREEN="\e[32m" YELLOW="\e[33m" RED="\e[31m" BLUE="\e[34m" RESET="\e[0m" # Logging functions log() { echo -e "${BLUE}[$(date +'%H:%M:%S')]${RESET} $1" } success() { echo -e "${GREEN}✅ $1${RESET}" } warning() { echo -e "${YELLOW}⚠️ $1${RESET}" } error() { echo -e "${RED}❌ $1${RESET}" cleanup_on_error exit 1 } # Cleanup on error cleanup_on_error() { log "Cleaning up after error..." if [[ -d "$BACKUP_PATH" ]]; then warning "Rolling back to previous version..." restore_backup "$BACKUP_PATH" fi } # Prerequisites check check_prerequisites() { log "Checking prerequisites..." # Check if running from project root if [[ ! -f "$PROJECT_ROOT/composer.json" ]]; then error "Must be run from project root directory" fi # Check Docker if ! command -v docker &> /dev/null; then error "Docker is not installed" fi # Check Docker Compose if ! docker compose version &> /dev/null; then error "Docker Compose is not installed" fi # Check .env.production exists if [[ ! -f "$PROJECT_ROOT/.env.production" ]]; then error ".env.production not found - copy from .env.example and configure" fi # Check docker-compose.production.yml exists if [[ ! -f "$PROJECT_ROOT/docker-compose.production.yml" ]]; then error "docker-compose.production.yml not found" fi # Verify VAULT_ENCRYPTION_KEY is set if ! grep -q "VAULT_ENCRYPTION_KEY=" "$PROJECT_ROOT/.env.production" || \ grep -q "VAULT_ENCRYPTION_KEY=CHANGE_ME" "$PROJECT_ROOT/.env.production"; then error "VAULT_ENCRYPTION_KEY not configured in .env.production" fi success "Prerequisites check passed" } # Create backup create_backup() { log "Creating backup..." mkdir -p "$BACKUP_DIR" # Backup database if docker compose ps db | grep -q "Up"; then log "Backing up database..." docker compose exec -T db pg_dump -U postgres michaelschiemer_prod | \ gzip > "${BACKUP_PATH}_database.sql.gz" success "Database backup created" fi # Backup .env if [[ -f "$PROJECT_ROOT/.env" ]]; then cp "$PROJECT_ROOT/.env" "${BACKUP_PATH}_env" success ".env backup created" fi # Backup docker volumes (important directories) if [[ -d "$PROJECT_ROOT/storage" ]]; then tar -czf "${BACKUP_PATH}_storage.tar.gz" -C "$PROJECT_ROOT" storage success "Storage backup created" fi success "Backup completed: $BACKUP_PATH" } # Restore from backup restore_backup() { local backup_path="$1" log "Restoring from backup: $backup_path" # Restore database if [[ -f "${backup_path}_database.sql.gz" ]]; then log "Restoring database..." gunzip -c "${backup_path}_database.sql.gz" | \ docker compose exec -T db psql -U postgres michaelschiemer_prod success "Database restored" fi # Restore .env if [[ -f "${backup_path}_env" ]]; then cp "${backup_path}_env" "$PROJECT_ROOT/.env" success ".env restored" fi # Restore storage if [[ -f "${backup_path}_storage.tar.gz" ]]; then tar -xzf "${backup_path}_storage.tar.gz" -C "$PROJECT_ROOT" success "Storage restored" fi # Restart services docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ restart success "Backup restored successfully" } # Build Docker images build_images() { log "Building Docker images..." cd "$PROJECT_ROOT" docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ build --no-cache success "Docker images built" } # Run database migrations run_migrations() { log "Running database migrations..." cd "$PROJECT_ROOT" # Check migration status first docker compose exec -T php php console.php db:status || true # Run migrations if ! docker compose exec -T php php console.php db:migrate; then error "Database migrations failed" fi success "Database migrations completed" } # Initialize SSL certificates init_ssl() { log "Initializing SSL certificates..." cd "$PROJECT_ROOT" # Check if SSL is enabled if grep -q "SSL_ENABLED=true" .env.production; then log "SSL is enabled, checking certificate status..." # Check certificate status if docker compose exec -T php php console.php ssl:status 2>/dev/null | grep -q "Certificate is valid"; then success "SSL certificate already exists and is valid" else warning "SSL certificate not found or invalid, initializing..." if ! docker compose exec -T php php console.php ssl:init; then error "SSL initialization failed" fi success "SSL certificate initialized" fi else warning "SSL is disabled in .env.production" fi } # Verify Vault configuration verify_vault() { log "Verifying Vault configuration..." cd "$PROJECT_ROOT" # Test Vault access if ! docker compose exec -T php php console.php vault:list &>/dev/null; then error "Vault not accessible - check VAULT_ENCRYPTION_KEY" fi success "Vault is configured correctly" } # Health check with retries health_check() { local max_retries=30 local retry_count=0 log "Running health checks..." while [[ $retry_count -lt $max_retries ]]; do if curl -f -s -k -H "User-Agent: Mozilla/5.0 (Deployment Health Check)" "https://localhost/health" > /dev/null 2>&1; then success "Health check passed" return 0 fi retry_count=$((retry_count + 1)) log "Health check attempt $retry_count/$max_retries..." sleep 2 done error "Health check failed after $max_retries attempts" } # Initial deployment initial_deployment() { log "🚀 Starting initial production deployment..." check_prerequisites cd "$PROJECT_ROOT" # 1. Generate Vault encryption key if not exists if grep -q "VAULT_ENCRYPTION_KEY=CHANGE_ME" .env.production; then log "Generating Vault encryption key..." warning "Make sure to backup this key securely!" # Key generation is done manually for security error "Please generate VAULT_ENCRYPTION_KEY with: docker exec php php console.php vault:generate-key" fi # 2. Build images build_images # 3. Start services log "Starting Docker services..." docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ up -d # 4. Wait for services to be ready log "Waiting for services to be ready..." sleep 20 # 5. Run migrations run_migrations # 6. Initialize SSL init_ssl # 7. Verify Vault verify_vault # 8. Health check health_check # 9. Display summary deployment_summary success "🎉 Initial deployment completed successfully!" } # Update deployment (zero-downtime) update_deployment() { log "🔄 Starting rolling update deployment..." check_prerequisites create_backup cd "$PROJECT_ROOT" # 1. Pull latest images (if using registry) log "Pulling latest images..." docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ pull || warning "Pull failed (not critical if building locally)" # 2. Build new images build_images # 3. Run migrations (if any) log "Running database migrations..." docker compose exec -T php php console.php db:migrate || warning "No new migrations" # 4. Rolling restart with health checks log "Performing rolling restart..." # Restart PHP-FPM first docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ up -d --no-deps --force-recreate php sleep 10 # Restart web server docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ up -d --no-deps --force-recreate web sleep 5 # Restart queue workers (graceful shutdown via stop_grace_period) docker compose -f docker-compose.yml \ -f docker-compose.production.yml \ --env-file .env.production \ up -d --no-deps --force-recreate --scale queue-worker=2 queue-worker # 5. Health check health_check # 6. Cleanup old images log "Cleaning up old Docker images..." docker image prune -f # 7. Display summary deployment_summary success "🎉 Update deployment completed successfully!" } # Rollback deployment rollback_deployment() { log "⏪ Starting rollback..." # Find latest backup local latest_backup=$(find "$BACKUP_DIR" -name "backup_*_database.sql.gz" | sort -r | head -1) if [[ -z "$latest_backup" ]]; then error "No backup found for rollback" fi local backup_prefix="${latest_backup%_database.sql.gz}" warning "Rolling back to: $backup_prefix" read -p "Continue? (yes/no): " confirm if [[ "$confirm" != "yes" ]]; then log "Rollback cancelled" exit 0 fi restore_backup "$backup_prefix" health_check success "🎉 Rollback completed successfully!" } # Deployment summary deployment_summary() { echo "" echo -e "${GREEN}========================================${RESET}" echo -e "${GREEN} Deployment Summary${RESET}" echo -e "${GREEN}========================================${RESET}" echo "" echo "📋 Mode: $DEPLOY_MODE" echo "⏰ Timestamp: $(date)" echo "📁 Project: $PROJECT_ROOT" echo "💾 Backup: $BACKUP_PATH" echo "" echo "🐳 Docker Services:" docker compose ps echo "" echo "🔒 Security Checks:" echo " [ ] APP_ENV=production in .env.production" echo " [ ] APP_DEBUG=false in .env.production" echo " [ ] VAULT_ENCRYPTION_KEY configured" echo " [ ] ADMIN_ALLOWED_IPS configured" echo " [ ] SSL certificates valid" echo "" echo "📊 Health Check:" echo " ✅ Application: https://localhost/health" echo "" echo "📝 Next Steps:" echo " 1. Verify all services are running" echo " 2. Check logs: docker compose logs -f --tail=100" echo " 3. Test critical user flows" echo " 4. Monitor error rates" echo "" echo -e "${GREEN}========================================${RESET}" } # Main deployment logic main() { case "$DEPLOY_MODE" in initial) initial_deployment ;; update) update_deployment ;; rollback) rollback_deployment ;; *) error "Invalid deployment mode: $DEPLOY_MODE. Use: initial|update|rollback" ;; esac } # Trap errors trap cleanup_on_error ERR # Run main main "$@"