#!/bin/bash # Safe Deployment Script for dk0.dev # Ensures secure, zero-downtime deployments with proper error handling and rollback set -euo pipefail # Exit on error, undefined vars, pipe failures # Configuration IMAGE_NAME="portfolio-app" NEW_TAG="latest" OLD_TAG="previous" BACKUP_TAG="backup-$(date +%Y%m%d-%H%M%S)" COMPOSE_FILE="docker-compose.production.yml" HEALTH_CHECK_URL="http://localhost:3000/api/health" HEALTH_CHECK_TIMEOUT=180 HEALTH_CHECK_INTERVAL=5 MAX_ROLLBACK_ATTEMPTS=3 # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Logging functions log() { echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" } error() { echo -e "${RED}[ERROR]${NC} $1" >&2 } success() { echo -e "${GREEN}[SUCCESS]${NC} $1" } warning() { echo -e "${YELLOW}[WARNING]${NC} $1" } # Cleanup function cleanup() { local exit_code=$? if [ $exit_code -ne 0 ]; then error "Deployment failed with exit code $exit_code" rollback fi } # Health check function check_health() { local url=$1 local max_attempts=$((HEALTH_CHECK_TIMEOUT / HEALTH_CHECK_INTERVAL)) local attempt=0 log "Performing health check on $url..." while [ $attempt -lt $max_attempts ]; do if curl -f -s -m 5 "$url" > /dev/null 2>&1; then return 0 fi attempt=$((attempt + 1)) sleep $HEALTH_CHECK_INTERVAL echo -n "." done echo "" return 1 } # Rollback function rollback() { error "Deployment failed. Initiating rollback..." local rollback_attempt=0 while [ $rollback_attempt -lt $MAX_ROLLBACK_ATTEMPTS ]; do rollback_attempt=$((rollback_attempt + 1)) log "Rollback attempt $rollback_attempt/$MAX_ROLLBACK_ATTEMPTS" # Try to restore from backup tag first if docker images | grep -q "${IMAGE_NAME}:${OLD_TAG}"; then log "Restoring from previous image..." docker tag "${IMAGE_NAME}:${OLD_TAG}" "${IMAGE_NAME}:${NEW_TAG}" || { warning "Failed to tag previous image" continue } elif docker images | grep -q "${IMAGE_NAME}:${BACKUP_TAG}"; then log "Restoring from backup image..." docker tag "${IMAGE_NAME}:${BACKUP_TAG}" "${IMAGE_NAME}:${NEW_TAG}" || { warning "Failed to tag backup image" continue } else error "No backup image found for rollback" return 1 fi # Restart container with previous image log "Restarting container with previous image..." docker-compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || { warning "Failed to restart container" continue } # Wait for health check sleep 10 if check_health "$HEALTH_CHECK_URL"; then success "Rollback successful!" return 0 else warning "Rollback attempt $rollback_attempt failed health check" fi done error "All rollback attempts failed" return 1 } # Pre-deployment checks pre_deployment_checks() { log "Running pre-deployment checks..." # Check if running as root if [[ $EUID -eq 0 ]]; then error "This script should not be run as root" exit 1 fi # Check if Docker is running if ! docker info > /dev/null 2>&1; then error "Docker is not running. Please start Docker and try again." exit 1 fi # Check if docker-compose is available if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then error "docker-compose is not installed" exit 1 fi # Check if .env file exists if [ ! -f .env ]; then error ".env file not found. Please create it before deploying." exit 1 fi # Check if required environment variables are set local required_vars=("DATABASE_URL" "NEXT_PUBLIC_BASE_URL") for var in "${required_vars[@]}"; do if ! grep -q "^${var}=" .env 2>/dev/null; then warning "Required environment variable $var not found in .env" fi done # Check disk space (at least 2GB free) local available_space=$(df -BG . | tail -1 | awk '{print $4}' | sed 's/G//') if [ "$available_space" -lt 2 ]; then error "Insufficient disk space. At least 2GB required, but only ${available_space}GB available." exit 1 fi success "Pre-deployment checks passed" } # Build application build_application() { log "Building application..." # Build Next.js application log "Building Next.js application..." npm ci --prefer-offline --no-audit || { error "npm install failed" exit 1 } npm run build || { error "Build failed" exit 1 } success "Application built successfully" } # Build Docker image build_docker_image() { log "Building Docker image..." # Backup current image if it exists if docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then log "Backing up current image..." docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:${OLD_TAG}" || { warning "Could not backup current image (this might be the first deployment)" } docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:${BACKUP_TAG}" || true fi # Build new image docker build -t "${IMAGE_NAME}:${NEW_TAG}" . || { error "Failed to build Docker image" exit 1 } # Verify new image if ! docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then error "New image not found after build" exit 1 fi success "Docker image built successfully" } # Deploy application deploy_application() { log "Deploying application..." # Start new container if command -v docker-compose &> /dev/null; then docker-compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || { error "Failed to start new container" exit 1 } else docker compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || { error "Failed to start new container" exit 1 } fi # Wait for container to start log "Waiting for container to start..." sleep 15 # Check if container is running if ! docker ps | grep -q "portfolio-app"; then error "Container failed to start" log "Container logs:" docker logs portfolio-app --tail=50 exit 1 fi success "Container started successfully" } # Run database migrations run_migrations() { log "Running database migrations..." # Wait for database to be ready local db_ready=false for i in {1..30}; do if docker exec portfolio-app npx prisma db push --skip-generate --accept-data-loss 2>&1 | grep -q "Database.*ready\|Everything is in sync"; then db_ready=true break fi sleep 2 done if [ "$db_ready" = false ]; then warning "Database might not be ready, but continuing..." fi # Run migrations docker exec portfolio-app npx prisma db push --skip-generate || { warning "Database migration had issues, but continuing..." } success "Database migrations completed" } # Verify deployment verify_deployment() { log "Verifying deployment..." # Health check if ! check_health "$HEALTH_CHECK_URL"; then error "Health check failed" log "Container logs:" docker logs portfolio-app --tail=100 return 1 fi # Test main page if ! curl -f -s -m 10 "http://localhost:3000/" > /dev/null 2>&1; then error "Main page is not accessible" return 1 fi # Test API endpoint if ! curl -f -s -m 10 "http://localhost:3000/api/health" > /dev/null 2>&1; then error "API health endpoint is not accessible" return 1 fi success "Deployment verification successful" } # Cleanup old images cleanup_old_images() { log "Cleaning up old images..." # Keep last 5 versions docker images "${IMAGE_NAME}" --format "{{.Tag}}" | \ grep -E "^(backup-|previous|failed-)" | \ sort -r | \ tail -n +6 | \ while read -r tag; do docker rmi "${IMAGE_NAME}:${tag}" 2>/dev/null || true done success "Cleanup completed" } # Main deployment flow main() { log "Starting safe deployment for dk0.dev..." # Set up error handling trap cleanup ERR # Run deployment steps pre_deployment_checks build_application build_docker_image deploy_application run_migrations # Verify deployment if verify_deployment; then cleanup_old_images # Show deployment info log "Deployment completed successfully!" log "Container status:" if command -v docker-compose &> /dev/null; then docker-compose -f "$COMPOSE_FILE" ps else docker compose -f "$COMPOSE_FILE" ps fi log "Resource usage:" docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" portfolio-app success "Application is available at: http://localhost:3000/" success "Health check endpoint: $HEALTH_CHECK_URL" # Disable error trap (deployment successful) trap - ERR else error "Deployment verification failed" exit 1 fi } # Run main function main "$@"