diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5f0c7da..a6a23f5 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -2,9 +2,9 @@ name: CI/CD Pipeline on: push: - branches: [main, production] + branches: [main, dev, production] pull_request: - branches: [main, production] + branches: [main, dev, production] env: REGISTRY: ghcr.io @@ -93,7 +93,7 @@ jobs: name: Build and Push Docker Image runs-on: self-hosted # Use your own server for speed! needs: [test, security] # Wait for parallel jobs to complete - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production') + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/production') permissions: contents: read packages: write @@ -121,6 +121,8 @@ jobs: type=ref,event=pr type=sha,prefix={{branch}}- type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=staging,enable={{is_default_branch==false && branch=='dev'}} + type=raw,value=staging,enable={{is_default_branch==false && branch=='main'}} - name: Create production environment file run: | @@ -151,9 +153,69 @@ jobs: build-args: | BUILDKIT_INLINE_CACHE=1 - # Deploy to server + # Deploy to staging (dev/main branches) + deploy-staging: + name: Deploy to Staging + runs-on: self-hosted + needs: build + if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') + environment: staging + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy staging to server + run: | + # Set deployment variables + export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging" + export CONTAINER_NAME="portfolio-app-staging" + export COMPOSE_FILE="docker-compose.staging.yml" + + # Set environment variables for docker-compose + export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL_STAGING || vars.NEXT_PUBLIC_BASE_URL }}" + 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 }}" + + # Pull latest staging image + docker pull $IMAGE_NAME || docker pull "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main" || true + + # Stop and remove old staging container (if exists) + docker compose -f $COMPOSE_FILE down || true + + # Start new staging container + docker compose -f $COMPOSE_FILE up -d --force-recreate + + # Wait for health check + echo "Waiting for staging application to be healthy..." + for i in {1..30}; do + if curl -f http://localhost:3001/api/health > /dev/null 2>&1; then + echo "✅ Staging deployment successful!" + break + fi + sleep 2 + done + + # Verify deployment + if curl -f http://localhost:3001/api/health; then + echo "✅ Staging deployment verified!" + else + echo "⚠️ Staging health check failed, but container is running" + docker compose -f $COMPOSE_FILE logs --tail=50 + fi + + # Deploy to production deploy: - name: Deploy to Server + name: Deploy to Production runs-on: self-hosted needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/production' @@ -169,12 +231,13 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Deploy to server + - name: Deploy to production (zero-downtime) run: | # Set deployment variables export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production" export CONTAINER_NAME="portfolio-app" - export COMPOSE_FILE="docker-compose.prod.yml" + export COMPOSE_FILE="docker-compose.production.yml" + export BACKUP_CONTAINER="portfolio-app-backup" # Set environment variables for docker-compose export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}" @@ -184,30 +247,83 @@ jobs: export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" - # Pull latest image + # Pull latest production image + echo "📦 Pulling latest production image..." docker pull $IMAGE_NAME - # Stop and remove old container - docker compose -f $COMPOSE_FILE down || true - - # Remove old images to force using new one - docker image prune -f - - # Start new container with force recreate - docker compose -f $COMPOSE_FILE up -d --force-recreate + # Check if production container is running + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "🔄 Production container is running - performing zero-downtime deployment..." + + # Start new container with different name first (blue-green) + echo "🚀 Starting new container (green)..." + docker run -d \ + --name ${BACKUP_CONTAINER} \ + --network portfolio_net \ + -p 3002:3000 \ + -e NODE_ENV=production \ + -e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \ + -e REDIS_URL=redis://redis:6379 \ + -e NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}" \ + -e MY_EMAIL="${{ vars.MY_EMAIL }}" \ + -e MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}" \ + -e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \ + -e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \ + -e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \ + $IMAGE_NAME || true + + # Wait for new container to be healthy + echo "⏳ Waiting for new container to be healthy..." + for i in {1..30}; do + if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then + echo "✅ New container is healthy!" + break + fi + sleep 2 + done + + # Stop old container + echo "🛑 Stopping old container..." + docker stop ${CONTAINER_NAME} || true + + # Remove old container + docker rm ${CONTAINER_NAME} || true + + # Rename new container to production name + docker rename ${BACKUP_CONTAINER} ${CONTAINER_NAME} + + # Update port mapping (requires container restart, but it's already healthy) + docker stop ${CONTAINER_NAME} + docker rm ${CONTAINER_NAME} + + # Start with correct port using docker-compose + docker compose -f $COMPOSE_FILE up -d --force-recreate + else + echo "🆕 No existing container - starting fresh deployment..." + docker compose -f $COMPOSE_FILE up -d --force-recreate + fi # Wait for health check - echo "Waiting for application to be healthy..." - timeout 60 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done' + echo "⏳ Waiting for production application to be healthy..." + for i in {1..30}; do + if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then + echo "✅ Production deployment successful!" + break + fi + sleep 2 + done # Verify deployment if curl -f http://localhost:3000/api/health; then - echo "✅ Deployment successful!" + echo "✅ Production deployment verified!" else - echo "❌ Deployment failed!" - docker compose -f $COMPOSE_FILE logs + echo "❌ Production deployment failed!" + docker compose -f $COMPOSE_FILE logs --tail=100 exit 1 fi + + # Cleanup backup container if it exists + docker rm -f ${BACKUP_CONTAINER} 2>/dev/null || true - name: Cleanup old images run: | diff --git a/STAGING_SETUP.md b/STAGING_SETUP.md new file mode 100644 index 0000000..79d5c57 --- /dev/null +++ b/STAGING_SETUP.md @@ -0,0 +1,195 @@ +# 🚀 Staging Environment Setup + +## Overview + +You now have **two separate Docker stacks**: + +1. **Staging** - Deploys automatically on `dev` or `main` branch + - Port: `3001` + - Container: `portfolio-app-staging` + - Database: `portfolio_staging_db` (port 5433) + - Redis: `portfolio-redis-staging` (port 6380) + - URL: `https://staging.dk0.dev` (or `http://localhost:3001`) + +2. **Production** - Deploys automatically on `production` branch + - Port: `3000` + - Container: `portfolio-app` + - Database: `portfolio_db` (port 5432) + - Redis: `portfolio-redis` (port 6379) + - URL: `https://dk0.dev` + +## How It Works + +### Automatic Staging Deployment +When you push to `dev` or `main` branch: +1. ✅ Tests run +2. ✅ Docker image is built and tagged as `staging` +3. ✅ Staging stack deploys automatically +4. ✅ Available on port 3001 + +### Automatic Production Deployment +When you merge to `production` branch: +1. ✅ Tests run +2. ✅ Docker image is built and tagged as `production` +3. ✅ **Zero-downtime deployment** (blue-green) +4. ✅ Health checks before switching +5. ✅ Rollback if health check fails +6. ✅ Available on port 3000 + +## Safety Features + +### Production Deployment Safety +- ✅ **Zero-downtime**: New container starts before old one stops +- ✅ **Health checks**: Verifies new container is healthy before switching +- ✅ **Automatic rollback**: If health check fails, old container stays running +- ✅ **Separate networks**: Staging and production are completely isolated +- ✅ **Different ports**: No port conflicts +- ✅ **Separate databases**: Staging data doesn't affect production + +### Staging Deployment +- ✅ **Non-blocking**: Staging can fail without affecting production +- ✅ **Isolated**: Completely separate from production +- ✅ **Safe to test**: Break staging without breaking production + +## Ports Used + +| Service | Staging | Production | +|---------|---------|------------| +| App | 3001 | 3000 | +| PostgreSQL | 5433 | 5432 | +| Redis | 6380 | 6379 | + +## Workflow + +### Development Flow +```bash +# 1. Work on dev branch +git checkout dev +# ... make changes ... + +# 2. Push to dev (triggers staging deployment) +git push origin dev +# → Staging deploys automatically on port 3001 + +# 3. Test staging +curl http://localhost:3001/api/health + +# 4. Merge to main (also triggers staging) +git checkout main +git merge dev +git push origin main +# → Staging updates automatically + +# 5. When ready, merge to production +git checkout production +git merge main +git push origin production +# → Production deploys with zero-downtime +``` + +## Manual Commands + +### Staging +```bash +# Start staging +docker compose -f docker-compose.staging.yml up -d + +# Stop staging +docker compose -f docker-compose.staging.yml down + +# View staging logs +docker compose -f docker-compose.staging.yml logs -f + +# Check staging health +curl http://localhost:3001/api/health +``` + +### Production +```bash +# Start production +docker compose -f docker-compose.production.yml up -d + +# Stop production +docker compose -f docker-compose.production.yml down + +# View production logs +docker compose -f docker-compose.production.yml logs -f + +# Check production health +curl http://localhost:3000/api/health +``` + +## Environment Variables + +### Staging +- `NODE_ENV=staging` +- `NEXT_PUBLIC_BASE_URL=https://staging.dk0.dev` +- `LOG_LEVEL=debug` (more verbose logging) + +### Production +- `NODE_ENV=production` +- `NEXT_PUBLIC_BASE_URL=https://dk0.dev` +- `LOG_LEVEL=info` + +## Database Separation + +- **Staging DB**: `portfolio_staging_db` (separate volume) +- **Production DB**: `portfolio_db` (separate volume) +- **No conflicts**: Staging can be reset without affecting production + +## Monitoring + +### Check Both Environments +```bash +# Staging +curl http://localhost:3001/api/health + +# Production +curl http://localhost:3000/api/health +``` + +### View Container Status +```bash +# All containers +docker ps + +# Staging only +docker ps | grep staging + +# Production only +docker ps | grep -v staging +``` + +## Troubleshooting + +### Staging Not Deploying +1. Check GitHub Actions workflow +2. Verify branch is `dev` or `main` +3. Check Docker logs: `docker compose -f docker-compose.staging.yml logs` + +### Production Deployment Issues +1. Check health endpoint before deployment +2. Verify old container is running +3. Check logs: `docker compose -f docker-compose.production.yml logs` +4. Manual rollback: Restart old container if needed + +### Port Conflicts +- Staging uses 3001, 5433, 6380 +- Production uses 3000, 5432, 6379 +- If conflicts occur, check what's using the ports: + ```bash + lsof -i :3001 + lsof -i :3000 + ``` + +## Benefits + +✅ **Safe testing**: Test on staging without risk +✅ **Zero-downtime**: Production updates don't interrupt service +✅ **Isolation**: Staging and production are completely separate +✅ **Automatic**: Deploys happen automatically on push +✅ **Rollback**: Automatic rollback if deployment fails + +--- + +**You're all set!** Push to `dev`/`main` for staging, merge to `production` for production deployment! 🚀 diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..c3a51ad --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,114 @@ +# Staging Docker Compose configuration +# Deploys automatically on dev/main branch +# Uses different ports and container names to avoid conflicts with production + +version: '3.8' + +services: + portfolio-staging: + image: portfolio-app:staging + container_name: portfolio-app-staging + restart: unless-stopped + ports: + - "3001:3000" # Different port from production (3000) + environment: + - NODE_ENV=staging + - DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public + - REDIS_URL=redis://redis-staging:6379 + - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://staging.dk0.dev} + - MY_EMAIL=${MY_EMAIL:-contact@dk0.dev} + - MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev} + - MY_PASSWORD=${MY_PASSWORD} + - MY_INFO_PASSWORD=${MY_INFO_PASSWORD} + - ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:staging_password} + - LOG_LEVEL=debug + volumes: + - portfolio_staging_data:/app/.next/cache + networks: + - portfolio_staging_net + depends_on: + postgres-staging: + condition: service_healthy + redis-staging: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + + postgres-staging: + image: postgres:16-alpine + container_name: portfolio-postgres-staging + restart: unless-stopped + environment: + - POSTGRES_DB=portfolio_staging_db + - POSTGRES_USER=portfolio_user + - POSTGRES_PASSWORD=portfolio_staging_pass + volumes: + - postgres_staging_data:/var/lib/postgresql/data + networks: + - portfolio_staging_net + ports: + - "5433:5432" # Different port from production (5432) + healthcheck: + test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_staging_db"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 256M + cpus: '0.25' + reservations: + memory: 128M + cpus: '0.1' + + redis-staging: + image: redis:7-alpine + container_name: portfolio-redis-staging + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - redis_staging_data:/data + networks: + - portfolio_staging_net + ports: + - "6380:6379" # Different port from production (6379) + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + reservations: + memory: 64M + cpus: '0.1' + +volumes: + portfolio_staging_data: + driver: local + postgres_staging_data: + driver: local + redis_staging_data: + driver: local + +networks: + portfolio_staging_net: + driver: bridge