Skip to content

chore: update docker-compose and CI/CD workflows to improve Redis man… #374

chore: update docker-compose and CI/CD workflows to improve Redis man…

chore: update docker-compose and CI/CD workflows to improve Redis man… #374

# ============================================================================
# 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"