blue-green deployment (build front back) #365
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================ | |
| # Production CI/CD Pipeline - Blue-Green Zero Downtime Deployment | |
| # ============================================================================ | |
| # | |
| # DIRECTORY STRUCTURE ON SERVER: | |
| # /opt/projects/prod.docs.plus/ | |
| # ├── .env ← Main environment file (you edit this) | |
| # └── app/docs.plus/docs.plus/ ← Git repo (GitHub Actions workspace) | |
| # ├── docker-compose.prod.yml | |
| # ├── .env.production ← Auto-generated from parent .env | |
| # └── packages/ | |
| # | |
| # The self-hosted runner workspace is: /opt/projects/prod.docs.plus/app/docs.plus/docs.plus | |
| # The .env file lives at: /opt/projects/prod.docs.plus/.env (3 levels up) | |
| # | |
| # ============================================================================ | |
| name: CI-Production-BlueGreen | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| env: | |
| # .env location (3 levels up from app directory) | |
| ENV_SOURCE: /opt/projects/prod.docs.plus/.env | |
| ENV_FILE: .env.production | |
| # Docker settings | |
| COMPOSE_FILE: docker-compose.prod.yml | |
| DOCKER_NETWORK: prod-docsplus-network | |
| # Ports for blue-green | |
| FRONTEND_PORT_BLUE: 3001 | |
| FRONTEND_PORT_GREEN: 3011 | |
| jobs: | |
| deploy-stack: | |
| name: 🚀 Deploy Full Stack (Blue-Green) | |
| runs-on: prod.docs.plus | |
| if: contains(github.event.head_commit.message, 'build') && (contains(github.event.head_commit.message, 'front') || contains(github.event.head_commit.message, 'back')) | |
| steps: | |
| - name: 📦 Checkout Code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| clean: false | |
| - name: 🥟 Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: 📥 Install Dependencies | |
| run: bun install --frozen-lockfile | |
| - name: 🔐 Prepare Environment | |
| run: | | |
| echo "📁 Current directory: $(pwd)" | |
| echo "📁 .env source: ${{ env.ENV_SOURCE }}" | |
| # Check if main .env exists | |
| if [ ! -f "${{ env.ENV_SOURCE }}" ]; then | |
| echo "❌ .env file not found at ${{ env.ENV_SOURCE }}" | |
| exit 1 | |
| fi | |
| # Copy .env to current directory as .env.production | |
| cp "${{ env.ENV_SOURCE }}" "${{ env.ENV_FILE }}" | |
| # Verify DATABASE_URL exists | |
| if ! grep -q "DATABASE_URL" "${{ env.ENV_FILE }}"; then | |
| echo "❌ DATABASE_URL not found in .env file" | |
| exit 1 | |
| fi | |
| echo "✅ Environment prepared" | |
| - name: 🔍 Detect Current Deployment Color | |
| id: detect-color | |
| run: | | |
| if docker ps --format '{{.Names}}' | grep -q "^prod-blue-"; then | |
| echo "CURRENT_COLOR=blue" >> $GITHUB_OUTPUT | |
| echo "NEW_COLOR=green" >> $GITHUB_OUTPUT | |
| echo "CURRENT_PORT=${{ env.FRONTEND_PORT_BLUE }}" >> $GITHUB_OUTPUT | |
| echo "NEW_PORT=${{ env.FRONTEND_PORT_GREEN }}" >> $GITHUB_OUTPUT | |
| echo "📘 Current: BLUE → Deploying: GREEN 🟢" | |
| elif docker ps --format '{{.Names}}' | grep -q "^prod-green-"; then | |
| echo "CURRENT_COLOR=green" >> $GITHUB_OUTPUT | |
| echo "NEW_COLOR=blue" >> $GITHUB_OUTPUT | |
| echo "CURRENT_PORT=${{ env.FRONTEND_PORT_GREEN }}" >> $GITHUB_OUTPUT | |
| echo "NEW_PORT=${{ env.FRONTEND_PORT_BLUE }}" >> $GITHUB_OUTPUT | |
| echo "🟢 Current: GREEN → Deploying: BLUE 📘" | |
| else | |
| echo "CURRENT_COLOR=none" >> $GITHUB_OUTPUT | |
| echo "NEW_COLOR=blue" >> $GITHUB_OUTPUT | |
| echo "CURRENT_PORT=none" >> $GITHUB_OUTPUT | |
| echo "NEW_PORT=${{ env.FRONTEND_PORT_BLUE }}" >> $GITHUB_OUTPUT | |
| echo "🆕 First deployment: BLUE 📘" | |
| fi | |
| - name: 🔍 Ensure Redis is Running | |
| run: | | |
| docker network create ${{ env.DOCKER_NETWORK }} 2>/dev/null || true | |
| if ! docker ps | grep -q "prod-docsplus-redis"; then | |
| echo "🚀 Starting Redis..." | |
| docker run -d \ | |
| --name prod-docsplus-redis \ | |
| --network ${{ env.DOCKER_NETWORK }} \ | |
| --restart unless-stopped \ | |
| -p 6379:6379 \ | |
| -v prod-redis-data:/data \ | |
| redis:alpine \ | |
| redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy noeviction | |
| sleep 5 | |
| fi | |
| docker exec prod-docsplus-redis redis-cli ping | grep -q PONG && echo "✅ Redis healthy" || exit 1 | |
| - name: 🏗️ Build Docker Images | |
| run: | | |
| echo "🔨 Building ${{ steps.detect-color.outputs.NEW_COLOR }} stack..." | |
| docker-compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| -p prod-${{ steps.detect-color.outputs.NEW_COLOR }} \ | |
| build | |
| echo "✅ Images built" | |
| - name: 🚀 Deploy New Stack | |
| run: | | |
| # Update env file with new frontend port | |
| sed -i "s/NGINX_HTTP_PORT=.*/NGINX_HTTP_PORT=${{ steps.detect-color.outputs.NEW_PORT }}/" ${{ env.ENV_FILE }} | |
| docker-compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| -p prod-${{ steps.detect-color.outputs.NEW_COLOR }} \ | |
| up -d \ | |
| --scale webapp=2 \ | |
| --scale rest-api=2 \ | |
| --scale hocuspocus-server=2 \ | |
| --scale hocuspocus-worker=1 | |
| echo "✅ Stack deployed" | |
| - name: ⏳ Wait for Services | |
| run: sleep 30 | |
| - name: 🩺 Health Check - Frontend | |
| run: | | |
| WEBAPP=$(docker ps --filter "name=prod-${{ steps.detect-color.outputs.NEW_COLOR }}-webapp" --format "{{.Names}}" | head -1) | |
| if [ -z "$WEBAPP" ]; then | |
| echo "❌ No webapp container found" | |
| exit 1 | |
| fi | |
| for i in {1..30}; do | |
| if docker exec $WEBAPP bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" &> /dev/null; then | |
| echo "✅ Frontend healthy" | |
| exit 0 | |
| fi | |
| echo "⏳ Attempt $i/30..." | |
| sleep 2 | |
| done | |
| echo "❌ Frontend health check failed" | |
| docker logs $WEBAPP --tail 100 | |
| exit 1 | |
| - name: 🩺 Health Check - Backend | |
| run: | | |
| REST=$(docker ps --filter "name=prod-${{ steps.detect-color.outputs.NEW_COLOR }}-rest-api" --format "{{.Names}}" | head -1) | |
| docker exec $REST bun -e "fetch('http://localhost:4000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" && echo "✅ REST API healthy" || (docker logs $REST --tail 50 && exit 1) | |
| WS=$(docker ps --filter "name=prod-${{ steps.detect-color.outputs.NEW_COLOR }}-hocuspocus-server" --format "{{.Names}}" | head -1) | |
| docker exec $WS bun -e "fetch('http://localhost:4001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" && echo "✅ WebSocket healthy" || (docker logs $WS --tail 50 && exit 1) | |
| WORKER=$(docker ps --filter "name=prod-${{ steps.detect-color.outputs.NEW_COLOR }}-hocuspocus-worker" --format "{{.Names}}" | head -1) | |
| docker exec $WORKER bun -e "fetch('http://localhost:4002/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" && echo "✅ Worker healthy" || (docker logs $WORKER --tail 50 && exit 1) | |
| - name: 🔄 Update Nginx | |
| run: | | |
| sudo cp /etc/nginx/sites-available/docs.plus /etc/nginx/sites-available/docs.plus.backup.$(date +%Y%m%d_%H%M%S) | |
| sudo sed -i "s/server localhost:[0-9]\+;/server localhost:${{ steps.detect-color.outputs.NEW_PORT }};/" /etc/nginx/sites-available/docs.plus | |
| sudo nginx -t && sudo nginx -s reload | |
| echo "✅ Nginx updated to port ${{ steps.detect-color.outputs.NEW_PORT }}" | |
| - name: ⏳ Grace Period | |
| run: sleep 15 | |
| - name: 🛑 Remove Old Stack | |
| if: steps.detect-color.outputs.CURRENT_COLOR != 'none' | |
| run: | | |
| docker-compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| -p prod-${{ steps.detect-color.outputs.CURRENT_COLOR }} \ | |
| down | |
| echo "✅ Old stack (${{ steps.detect-color.outputs.CURRENT_COLOR }}) removed" | |
| - name: 🧹 Cleanup | |
| run: | | |
| docker image prune -f | |
| docker volume prune -f --filter "label!=keep" | |
| - name: 📊 Summary | |
| run: | | |
| echo "======================================" | |
| echo "✅ DEPLOYMENT SUCCESSFUL" | |
| echo "======================================" | |
| echo "Stack: prod-${{ steps.detect-color.outputs.NEW_COLOR }}" | |
| echo "Port: ${{ steps.detect-color.outputs.NEW_PORT }}" | |
| docker ps --filter "name=prod-${{ steps.detect-color.outputs.NEW_COLOR }}-" --format "table {{.Names}}\t{{.Status}}" | |
| echo "URLs: https://docs.plus | https://prodback.docs.plus" | |
| echo "======================================" | |
| # ========================================================================== | |
| # UPTIME KUMA | |
| # ========================================================================== | |
| deploy-uptime-kuma: | |
| name: 🔔 Deploy Uptime Kuma | |
| runs-on: prod.docs.plus | |
| if: contains(github.event.head_commit.message, 'build') && contains(github.event.head_commit.message, 'uptime-kuma') | |
| steps: | |
| - name: 🚀 Deploy | |
| run: | | |
| docker network create ${{ env.DOCKER_NETWORK }} 2>/dev/null || true | |
| docker stop uptime-kuma 2>/dev/null || true | |
| docker rm uptime-kuma 2>/dev/null || true | |
| docker run -d \ | |
| --name uptime-kuma \ | |
| --network ${{ env.DOCKER_NETWORK }} \ | |
| --restart unless-stopped \ | |
| -p 3001:3001 \ | |
| -v uptime-kuma-data:/app/data \ | |
| louislam/uptime-kuma:latest | |
| sleep 15 | |
| curl -f -s http://localhost:3001 > /dev/null && echo "✅ Uptime Kuma running" || echo "⚠️ May need more time" |