chore: update docker-compose and CI/CD workflows to improve Redis man… #374
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) | |
| # | |
| # ============================================================================ | |
| name: CI-Production-BlueGreen | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| env: | |
| ENV_SOURCE: /opt/projects/prod.docs.plus/.env | |
| ENV_FILE: .env.production | |
| COMPOSE_FILE: docker-compose.prod.yml | |
| DOCKER_NETWORK: prod-docsplus-network | |
| 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 }}" | |
| if [ ! -f "${{ env.ENV_SOURCE }}" ]; then | |
| echo "❌ .env file not found at ${{ env.ENV_SOURCE }}" | |
| exit 1 | |
| fi | |
| cp "${{ env.ENV_SOURCE }}" "${{ env.ENV_FILE }}" | |
| 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: 🧹 Pre-deploy Cleanup | |
| run: | | |
| # Create network if not exists | |
| docker network create ${{ env.DOCKER_NETWORK }} 2>/dev/null || true | |
| # Clean up any failed/orphan containers from the NEW stack (in case previous deploy failed) | |
| NEW_COLOR="${{ steps.detect-color.outputs.NEW_COLOR }}" | |
| echo "🧹 Cleaning up any orphan containers from prod-${NEW_COLOR}..." | |
| docker-compose -f ${{ env.COMPOSE_FILE }} --env-file ${{ env.ENV_FILE }} -p prod-${NEW_COLOR} down --remove-orphans 2>/dev/null || true | |
| # Ensure Redis is running (shared across blue/green - started once, never rotated) | |
| if docker ps --format '{{.Names}}' | grep -q "^prod-docsplus-redis$"; then | |
| echo "✅ Redis already running" | |
| elif docker ps -a --format '{{.Names}}' | grep -q "^prod-docsplus-redis$"; then | |
| echo "🔄 Starting stopped Redis container..." | |
| docker start prod-docsplus-redis | |
| else | |
| echo "🚀 Starting Redis (shared infrastructure)..." | |
| docker run -d \ | |
| --name prod-docsplus-redis \ | |
| --network ${{ env.DOCKER_NETWORK }} \ | |
| --restart unless-stopped \ | |
| -v docsy-redis-prod:/data \ | |
| redis:alpine \ | |
| redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy noeviction | |
| fi | |
| # Wait for Redis to be ready | |
| sleep 3 | |
| docker exec prod-docsplus-redis redis-cli ping | grep -q PONG && echo "✅ Redis healthy" || echo "⚠️ Redis may need more time" | |
| - name: 🏗️ Build Docker Images | |
| run: | | |
| echo "🔨 Building ${{ steps.detect-color.outputs.NEW_COLOR }} stack..." | |
| # Source all env vars so docker-compose can use them for build args | |
| set -a | |
| source ${{ env.ENV_FILE }} | |
| set +a | |
| echo "DATABASE_URL is set: ${DATABASE_URL:0:50}..." | |
| 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: | | |
| sed -i "s/NGINX_HTTP_PORT=.*/NGINX_HTTP_PORT=${{ steps.detect-color.outputs.NEW_PORT }}/" ${{ env.ENV_FILE }} | |
| # Deploy app services only (redis/postgres/nginx have profiles, won't start by default) | |
| 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 | |
| # Connect app containers to the shared network where Redis lives | |
| for container in $(docker ps --filter "name=prod-${{ steps.detect-color.outputs.NEW_COLOR }}-" --format "{{.Names}}"); do | |
| docker network connect ${{ env.DOCKER_NETWORK }} $container 2>/dev/null || true | |
| done | |
| 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 "======================================" | |
| - name: 🚨 Cleanup on Failure | |
| if: failure() | |
| run: | | |
| echo "⚠️ Deployment failed - cleaning up failed stack..." | |
| NEW_COLOR="${{ steps.detect-color.outputs.NEW_COLOR }}" | |
| # Stop and remove the failed stack's containers | |
| docker-compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| -p prod-${NEW_COLOR} \ | |
| down --remove-orphans 2>/dev/null || true | |
| # Also clean up any orphan containers with the new color prefix | |
| docker ps -a --filter "name=prod-${NEW_COLOR}-" --format "{{.Names}}" | xargs -r docker rm -f 2>/dev/null || true | |
| echo "🧹 Failed stack cleaned up" | |
| echo "💡 Fix the issue and push again to retry deployment" | |
| 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" |