name: Production Deployment (Zero Downtime) on: push: branches: [ production ] env: NODE_VERSION: '20' DOCKER_IMAGE: portfolio-app IMAGE_TAG: production jobs: deploy-production: runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label steps: - name: Checkout code uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run linting and tests in parallel run: | npm run lint & LINT_PID=$! npm run test:production & TEST_PID=$! wait $LINT_PID $TEST_PID - name: Build application run: npm run build - name: Build Docker image run: | echo "๐Ÿ—๏ธ Building production Docker image with BuildKit cache..." DOCKER_BUILDKIT=1 docker build \ --cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ --cache-from ${{ env.DOCKER_IMAGE }}:latest \ -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ -t ${{ env.DOCKER_IMAGE }}:latest \ . echo "โœ… Docker image built successfully" - name: Zero-Downtime Production Deployment run: | echo "๐Ÿš€ Starting zero-downtime production deployment..." COMPOSE_FILE="docker-compose.production.yml" CONTAINER_NAME="portfolio-app" HEALTH_PORT="3000" # Backup current container ID if running (exact name match to avoid staging) OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "") # Export environment variables for docker-compose export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}" export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}" export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}" # Also export other variables that docker-compose needs export MY_EMAIL="${{ vars.MY_EMAIL }}" export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}" export MY_PASSWORD="${{ secrets.MY_PASSWORD }}" export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}" # Start new container with updated image (docker-compose will handle this) echo "๐Ÿ†• Starting new production container..." echo "๐Ÿ“ Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}" docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio # Wait for new container to be healthy echo "โณ Waiting for new container to be healthy..." HEALTH_CHECK_PASSED=false for i in {1..90}; do # Get the production container ID (exact name match, exclude staging) # Use compose project to ensure we get the right container NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) if [ -z "$NEW_CONTAINER" ]; then # Fallback: try exact name match with leading slash NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") fi if [ ! -z "$NEW_CONTAINER" ]; then # Verify it's actually the production container by checking compose project label CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "") CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "") if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then # Check Docker health status first (most reliable) HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") if [ "$HEALTH" == "healthy" ]; then echo "โœ… New container is healthy (Docker health check)!" # Also verify HTTP endpoint from inside container if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then echo "โœ… Container HTTP endpoint is also responding!" HEALTH_CHECK_PASSED=true break else echo "โš ๏ธ Docker health check passed, but HTTP endpoint test failed. Continuing..." fi fi # Try HTTP health endpoint from host (may not work if port not mapped yet) if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then echo "โœ… New container is responding to HTTP health check from host!" HEALTH_CHECK_PASSED=true break fi # Show container status for debugging if [ $((i % 10)) -eq 0 ]; then echo "๐Ÿ“Š Container ID: $NEW_CONTAINER" echo "๐Ÿ“Š Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')" echo "๐Ÿ“Š Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')" echo "๐Ÿ“Š Health status: $HEALTH" echo "๐Ÿ“Š Testing from inside container:" docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed" docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true fi else echo "โš ๏ธ Found container but it's not from production compose file (skipping): $NEW_CONTAINER" fi fi echo "โณ Waiting... ($i/90)" sleep 2 done # Final verification: Check Docker health status (most reliable) NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) if [ -z "$NEW_CONTAINER" ]; then NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") fi if [ ! -z "$NEW_CONTAINER" ]; then FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown") if [ "$FINAL_HEALTH" == "healthy" ]; then echo "โœ… Final verification: Container is healthy!" HEALTH_CHECK_PASSED=true fi fi # Verify new container is working if [ "$HEALTH_CHECK_PASSED" != "true" ]; then echo "โŒ New container failed health check!" echo "๐Ÿ“‹ All running containers with 'portfolio' in name:" docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}" echo "๐Ÿ“‹ Production container from compose:" docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose" echo "๐Ÿ“‹ Container logs:" docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs" # Get the correct container ID NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) if [ -z "$NEW_CONTAINER" ]; then NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") fi if [ ! -z "$NEW_CONTAINER" ]; then echo "๐Ÿ“‹ Container inspect (ID: $NEW_CONTAINER):" docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found" echo "๐Ÿ“‹ Testing health endpoint from inside container:" docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed" # Check Docker health status - if it's healthy, accept it FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown") if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then echo "โœ… Docker health check reports healthy - accepting deployment!" HEALTH_CHECK_PASSED=true else echo "โŒ Docker health check also reports: $FINAL_HEALTH_CHECK" exit 1 fi else echo "โš ๏ธ Could not find production container!" exit 1 fi fi # Remove old container if it exists and is different if [ ! -z "$OLD_CONTAINER" ]; then # Get the new production container ID NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1) if [ -z "$NEW_CONTAINER" ]; then NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") fi if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then echo "๐Ÿงน Removing old container..." docker stop $OLD_CONTAINER 2>/dev/null || true docker rm $OLD_CONTAINER 2>/dev/null || true fi fi echo "โœ… Production deployment completed with zero downtime!" env: NODE_ENV: production LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} MY_EMAIL: ${{ vars.MY_EMAIL }} MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} MY_PASSWORD: ${{ secrets.MY_PASSWORD }} MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} - name: Production Health Check run: | echo "๐Ÿ” Running production health checks..." COMPOSE_FILE="docker-compose.production.yml" CONTAINER_NAME="portfolio-app" # Get the production container ID CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) if [ -z "$CONTAINER_ID" ]; then CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") fi if [ -z "$CONTAINER_ID" ]; then echo "โŒ Production container not found!" docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" exit 1 fi echo "๐Ÿ“ฆ Found container: $CONTAINER_ID" # Wait for container to be healthy (using Docker's health check) HEALTH_CHECK_PASSED=false for i in {1..30}; do HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown") if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then echo "โœ… Container is healthy and running!" # Test from inside the container (most reliable) if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then echo "โœ… Health endpoint responds from inside container!" HEALTH_CHECK_PASSED=true break else echo "โš ๏ธ Container is healthy but HTTP endpoint test failed. Retrying..." fi fi if [ $((i % 5)) -eq 0 ]; then echo "๐Ÿ“Š Status: $STATUS, Health: $HEALTH (attempt $i/30)" fi echo "โณ Waiting for production... ($i/30)" sleep 2 done if [ "$HEALTH_CHECK_PASSED" != "true" ]; then echo "โŒ Production health check failed!" echo "๐Ÿ“‹ Container status:" docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container" echo "๐Ÿ“‹ Container logs:" docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs" echo "๐Ÿ“‹ Testing from inside container:" docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed" exit 1 fi echo "โœ… Production is fully operational!" - name: Cleanup run: | echo "๐Ÿงน Cleaning up old images..." docker image prune -f echo "โœ… Cleanup completed"