build front back #411
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 - Traefik Zero-Downtime Deployment | |
| # ============================================================================ | |
| # | |
| # Architecture: | |
| # Traefik (SSL/LB) → Services (auto-discovered via Docker labels) | |
| # | |
| # Deployment Strategy: | |
| # Blue-Green rolling: New containers start → health check passes → old removed | |
| # Traefik automatically routes to healthy containers only | |
| # | |
| # ============================================================================ | |
| name: CI-Production | |
| 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 | |
| DEPLOY_TAG: ${{ github.sha }} | |
| jobs: | |
| deploy: | |
| name: 🚀 Deploy to Production | |
| 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 | |
| - 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: | | |
| # Copy production env file | |
| cp "${{ env.ENV_SOURCE }}" "${{ env.ENV_FILE }}" | |
| echo "DEPLOY_TAG=${{ env.DEPLOY_TAG }}" >> "${{ env.ENV_FILE }}" | |
| echo "✅ Environment ready" | |
| - name: 🏗️ Build Docker Images | |
| run: | | |
| echo "🔨 Building images with tag: ${{ env.DEPLOY_TAG }}" | |
| # Load env vars for build args | |
| set -a | |
| source ${{ env.ENV_FILE }} | |
| set +a | |
| docker compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| build --parallel | |
| echo "✅ Images built" | |
| - name: 🔧 Ensure Infrastructure (Traefik + Redis) | |
| run: | | |
| echo "🔧 Ensuring infrastructure is running..." | |
| # Create network if not exists | |
| docker network create docsplus-network 2>/dev/null || true | |
| # Start Traefik and Redis with --no-recreate (don't restart if running) | |
| # This prevents Traefik restart which causes downtime | |
| docker compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| up -d --no-recreate traefik redis | |
| # Only if Traefik is not running at all, start it | |
| if ! docker ps --filter "name=traefik" --filter "status=running" | grep -q traefik; then | |
| echo "⚠️ Traefik not running, starting..." | |
| docker compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| up -d traefik | |
| sleep 15 | |
| fi | |
| # Wait for Traefik to be healthy | |
| echo "⏳ Waiting for Traefik health..." | |
| for i in {1..30}; do | |
| if docker ps --filter "name=traefik" --filter "health=healthy" | grep -q traefik; then | |
| echo "✅ Traefik is healthy" | |
| break | |
| fi | |
| if [ $i -eq 30 ]; then | |
| echo "⚠️ Traefik health timeout, but continuing..." | |
| fi | |
| sleep 2 | |
| done | |
| - name: 🚀 Deploy Services (Zero-Downtime) | |
| run: | | |
| echo "🚀 Starting zero-downtime deployment..." | |
| # Function to deploy a service with true zero-downtime | |
| # Strategy: | |
| # 1. Start NEW containers (with new image) | |
| # 2. Wait for them to be healthy | |
| # 3. Traefik auto-routes to healthy containers | |
| # 4. Stop OLD containers | |
| deploy_service() { | |
| local SERVICE=$1 | |
| local TARGET_REPLICAS=$2 | |
| echo "" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| echo "📦 Deploying $SERVICE (target: $TARGET_REPLICAS replicas)" | |
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| # Get OLD container IDs (before deployment) | |
| OLD_CONTAINERS=$(docker ps -q --filter "label=com.docker.compose.service=${SERVICE}" 2>/dev/null | tr '\n' ' ' || true) | |
| OLD_COUNT=$(echo "$OLD_CONTAINERS" | wc -w | tr -d ' ') | |
| echo "📊 Current containers: $OLD_COUNT" | |
| # Step 1: Scale UP - force new containers with new image | |
| # Using --force-recreate would stop old first, so we scale to double instead | |
| SCALE_UP=$((OLD_COUNT + TARGET_REPLICAS)) | |
| if [ "$SCALE_UP" -lt "$TARGET_REPLICAS" ]; then | |
| SCALE_UP=$TARGET_REPLICAS | |
| fi | |
| echo "⬆️ Starting $TARGET_REPLICAS NEW containers (total will be $SCALE_UP)..." | |
| docker compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| up -d --no-deps --scale ${SERVICE}=${SCALE_UP} ${SERVICE} | |
| # Step 2: Wait for NEW containers to become healthy | |
| echo "⏳ Waiting for healthy containers..." | |
| for i in {1..90}; do | |
| HEALTHY=$(docker ps --filter "label=com.docker.compose.service=${SERVICE}" --filter "health=healthy" -q 2>/dev/null | wc -l | tr -d ' ') | |
| if [ "$HEALTHY" -ge "$TARGET_REPLICAS" ]; then | |
| echo "✅ $SERVICE: $HEALTHY healthy containers" | |
| break | |
| fi | |
| if [ $i -eq 90 ]; then | |
| echo "⚠️ Timeout waiting for healthy containers ($HEALTHY/$TARGET_REPLICAS)" | |
| fi | |
| if [ $((i % 5)) -eq 0 ]; then | |
| echo " ... ($HEALTHY/$TARGET_REPLICAS healthy, attempt $i/90)" | |
| fi | |
| sleep 2 | |
| done | |
| # Step 3: Remove OLD containers explicitly (they have old image) | |
| if [ -n "$OLD_CONTAINERS" ] && [ "$OLD_COUNT" -gt 0 ]; then | |
| echo "🗑️ Removing $OLD_COUNT old containers..." | |
| for container in $OLD_CONTAINERS; do | |
| docker stop "$container" --time 10 2>/dev/null || true | |
| docker rm "$container" 2>/dev/null || true | |
| done | |
| fi | |
| # Step 4: Ensure we have exactly TARGET_REPLICAS | |
| echo "📏 Ensuring exactly $TARGET_REPLICAS replicas..." | |
| docker compose -f ${{ env.COMPOSE_FILE }} \ | |
| --env-file ${{ env.ENV_FILE }} \ | |
| up -d --no-deps --scale ${SERVICE}=${TARGET_REPLICAS} ${SERVICE} | |
| # Verify | |
| sleep 3 | |
| FINAL=$(docker ps --filter "label=com.docker.compose.service=${SERVICE}" -q | wc -l | tr -d ' ') | |
| FINAL_HEALTHY=$(docker ps --filter "label=com.docker.compose.service=${SERVICE}" --filter "health=healthy" -q | wc -l | tr -d ' ') | |
| echo "✅ $SERVICE: $FINAL running, $FINAL_HEALTHY healthy" | |
| } | |
| # Deploy services in order (backend first, then frontend) | |
| deploy_service "rest-api" 2 | |
| deploy_service "hocuspocus-server" 2 | |
| deploy_service "hocuspocus-worker" 1 | |
| deploy_service "webapp" 2 | |
| echo "" | |
| echo "✅ All services deployed" | |
| - name: 🩺 Verify Deployment | |
| run: | | |
| echo "🩺 Verifying deployment..." | |
| # Wait a bit for everything to stabilize | |
| sleep 10 | |
| # Check all core services | |
| echo "📊 Service Status:" | |
| for svc in traefik docsplus-redis; do | |
| if docker ps --filter "name=$svc" --filter "status=running" | grep -q "$svc"; then | |
| echo " ✅ $svc: running" | |
| else | |
| echo " ❌ $svc: NOT running" | |
| docker logs $svc --tail 30 2>/dev/null || true | |
| exit 1 | |
| fi | |
| done | |
| # Check scaled services | |
| for svc in webapp rest-api hocuspocus-server hocuspocus-worker; do | |
| RUNNING=$(docker ps --filter "label=com.docker.compose.service=${svc}" --filter "status=running" --format "{{.Names}}" | wc -l) | |
| HEALTHY=$(docker ps --filter "label=com.docker.compose.service=${svc}" --filter "health=healthy" --format "{{.Names}}" | wc -l) | |
| if [ "$RUNNING" -gt 0 ]; then | |
| echo " ✅ $svc: $RUNNING running, $HEALTHY healthy" | |
| else | |
| echo " ❌ $svc: NOT running" | |
| exit 1 | |
| fi | |
| done | |
| # Health check via Traefik endpoints | |
| echo "" | |
| echo "🔍 Testing endpoints..." | |
| # Test main site | |
| for i in {1..20}; do | |
| HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" https://docs.plus/ 2>/dev/null || echo "000") | |
| if [ "$HTTP_CODE" = "200" ]; then | |
| echo " ✅ https://docs.plus/ → $HTTP_CODE" | |
| break | |
| fi | |
| if [ $i -eq 20 ]; then | |
| echo " ⚠️ https://docs.plus/ → $HTTP_CODE (may still be provisioning)" | |
| fi | |
| sleep 3 | |
| done | |
| # Test API health | |
| HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" https://prodback.docs.plus/api/health 2>/dev/null || echo "000") | |
| if [ "$HTTP_CODE" = "200" ]; then | |
| echo " ✅ https://prodback.docs.plus/api/health → $HTTP_CODE" | |
| else | |
| echo " ⚠️ https://prodback.docs.plus/api/health → $HTTP_CODE" | |
| fi | |
| echo "" | |
| echo "✅ Deployment verified" | |
| - name: 🔄 Ensure Services from Production Directory | |
| run: | | |
| cd /opt/projects/prod.docs.plus/app/docs.plus/docs.plus | |
| docker compose -f ${{ env.COMPOSE_FILE }} --env-file ${{ env.ENV_FILE }} up -d \ | |
| rest-api hocuspocus-server hocuspocus-worker webapp | |
| echo "✅ Services running" | |
| - name: 🧹 Cleanup | |
| run: | | |
| # Remove dangling images | |
| docker image prune -f | |
| # Remove old images (older than 24h) | |
| docker image prune -f --filter "until=24h" 2>/dev/null || true | |
| echo "✅ Cleanup complete" | |
| - name: 📊 Summary | |
| run: | | |
| echo "======================================" | |
| echo "✅ DEPLOYMENT SUCCESSFUL" | |
| echo "======================================" | |
| echo "Tag: ${{ env.DEPLOY_TAG }}" | |
| echo "" | |
| echo "Services:" | |
| docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(traefik|docsplus|webapp|rest-api|hocuspocus)" | head -15 | |
| echo "" | |
| echo "URLs:" | |
| echo " - https://docs.plus" | |
| echo " - https://prodback.docs.plus" | |
| echo "======================================" | |
| - name: 🚨 Rollback on Failure | |
| if: failure() | |
| run: | | |
| echo "⚠️ Deployment failed - attempting recovery..." | |
| # Don't do aggressive rollback - just ensure services are running | |
| # Traefik will route to whatever containers are healthy | |
| cd /opt/projects/prod.docs.plus/app/docs.plus/docs.plus | |
| docker compose -f ${{ env.COMPOSE_FILE }} --env-file ${{ env.ENV_FILE }} \ | |
| up -d --no-recreate 2>/dev/null || true | |
| echo "📊 Current state:" | |
| docker ps --format "table {{.Names}}\t{{.Status}}" | head -15 | |
| # =========================================================================== | |
| # UPTIME KUMA (Monitoring) | |
| # =========================================================================== | |
| 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 docsplus-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 docsplus-network \ | |
| --restart unless-stopped \ | |
| -v uptime-kuma-data:/app/data \ | |
| --label "traefik.enable=true" \ | |
| --label "traefik.http.routers.uptime.rule=Host(\`status.docs.plus\`)" \ | |
| --label "traefik.http.routers.uptime.entrypoints=websecure" \ | |
| --label "traefik.http.routers.uptime.tls.certresolver=letsencrypt" \ | |
| --label "traefik.http.services.uptime.loadbalancer.server.port=3001" \ | |
| louislam/uptime-kuma:latest | |
| sleep 15 | |
| echo "✅ Uptime Kuma deployed at https://status.docs.plus" |