chore: enhance Docker configurations and CI/CD workflows for improved… #370
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: 🧹 Cleanup Old Manual Redis (One-time migration) | |
| run: | | |
| # Remove any manually-created Redis containers from old workflow | |
| # Compose will now manage Redis with the same container name | |
| if docker ps -a --format '{{.Names}}' | grep -q "^prod-docsplus-redis$"; then | |
| echo "🔄 Found old Redis container, checking if compose-managed..." | |
| # If Redis was created by docker run (not compose), remove it | |
| if ! docker inspect prod-docsplus-redis --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null | grep -q "prod-"; then | |
| echo "🗑️ Removing manually-created Redis (compose will recreate it)" | |
| docker stop prod-docsplus-redis 2>/dev/null || true | |
| docker rm prod-docsplus-redis 2>/dev/null || true | |
| else | |
| echo "✅ Redis already managed by compose" | |
| fi | |
| fi | |
| # Create network if not exists | |
| docker network create ${{ env.DOCKER_NETWORK }} 2>/dev/null || true | |
| - 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 }} | |
| 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 "======================================" | |
| 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" |