diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..538ba6f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,113 @@ +name: Build & Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: production-deploy + cancel-in-progress: true + +jobs: + build-and-push-images: + name: Build & Push Docker Images + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set image namespace + id: vars + run: echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-backend:latest + ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-backend:${{ github.sha }} + + - name: Build and push frontend image + uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + build-args: | + NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-key + SUPABASE_SERVICE_ROLE_KEY=placeholder-service-key + tags: | + ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-frontend:latest + ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-frontend:${{ github.sha }} + + deploy-to-server: + name: Deploy on Server + runs-on: ubuntu-latest + needs: build-and-push-images + + steps: + - uses: actions/checkout@v4 + + - name: Upload production compose file + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || '22' }} + source: "devops/docker-compose.prod.yml" + target: ${{ secrets.DEPLOY_PATH }} + strip_components: 1 + + - name: Upload deploy script + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || '22' }} + source: "devops/scripts/deploy-prod.sh" + target: ${{ secrets.DEPLOY_PATH }} + strip_components: 2 + + - name: Deploy containers + uses: appleboy/ssh-action@v1.2.0 + env: + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + GHCR_READ_TOKEN: ${{ secrets.GHCR_READ_TOKEN }} + IMAGE_TAG: ${{ github.sha }} + GHCR_OWNER: ${{ github.repository_owner }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || '22' }} + envs: DEPLOY_PATH,GHCR_USERNAME,GHCR_READ_TOKEN,IMAGE_TAG,GHCR_OWNER + script_stop: true + script: | + set -euo pipefail + cd "${DEPLOY_PATH}" + chmod +x deploy-prod.sh + GHCR_OWNER="$(echo "${GHCR_OWNER}" | tr '[:upper:]' '[:lower:]')" ./deploy-prod.sh + curl -fsS "http://localhost:5000/health" >/dev/null + curl -fsS "http://localhost:3000" >/dev/null diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8495b6c..0e61638 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,13 +19,17 @@ jobs: - name: Start services run: | - docker compose -f devops/docker-compose.yml up -d db backend - sleep 15 + docker compose -f devops/docker-compose.yml up -d db backend frontend + sleep 30 - name: Verify backend health run: | docker compose -f devops/docker-compose.yml exec -T backend node -e "const h=require('http');h.get('http://localhost:5000/health',(r)=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{console.log(d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error(e);process.exit(1)})" + - name: Verify frontend health + run: | + curl -fsS http://localhost:3000 >/dev/null + - name: Cleanup if: always() run: docker compose -f devops/docker-compose.yml down -v diff --git a/README.md b/README.md index 8372124..01ae62d 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,37 @@ npm run test:frontend |----------|---------|-------------| | `backend.yml` | Push/PR to backend | Lint → Test → Build (Node 18 & 20) | | `frontend.yml` | Push/PR to frontend | Lint → Test → Build (Node 18 & 20) | -| `docker.yml` | Push/PR to main | Docker Compose build → Backend health check | +| `docker.yml` | Push/PR to main | Docker Compose build → Backend + Frontend health checks | +| `deploy.yml` | Push to `main` / manual dispatch | Build + push Docker images to GHCR, then deploy on your server via SSH | + +## 🚢 Production Deployment (Own Server) + +The repository now includes a full CI/CD path for server deployment: + +1. `deploy.yml` builds both images (`cloudlense-backend`, `cloudlense-frontend`) and pushes them to GHCR. +2. The same workflow uploads `devops/docker-compose.prod.yml` + `devops/scripts/deploy-prod.sh` to your server. +3. It then pulls the new image tag (`github.sha`) and restarts the stack with Docker Compose. + +Server setup once: + +```bash +mkdir -p /opt/cloudlense +cd /opt/cloudlense +cp /path/to/repo/devops/.env.production.example .env +# fill .env with real values +``` + +Required GitHub repository secrets for deployment: + +| Secret | Description | +|--------|-------------| +| `DEPLOY_HOST` | Server hostname or IP | +| `DEPLOY_PORT` | SSH port (usually `22`) | +| `DEPLOY_USER` | SSH user with Docker permissions | +| `DEPLOY_SSH_KEY` | Private SSH key for `DEPLOY_USER` | +| `DEPLOY_PATH` | Deployment directory on server (e.g. `/opt/cloudlense`) | +| `GHCR_USERNAME` | GitHub username for GHCR pull | +| `GHCR_READ_TOKEN` | GitHub token/PAT with `read:packages` | ## 🔑 Key API Routes diff --git a/devops/.env.production.example b/devops/.env.production.example new file mode 100644 index 0000000..5d40bb8 --- /dev/null +++ b/devops/.env.production.example @@ -0,0 +1,34 @@ +# Required image/deployment settings +GHCR_OWNER=denshooter +IMAGE_TAG=latest + +# Host ports +FRONTEND_PORT=3000 +BACKEND_PORT=5000 + +# PostgreSQL +POSTGRES_USER=monitoring +POSTGRES_PASSWORD=replace-with-strong-password +POSTGRES_DB=monitoring + +# App URLs and backend CORS +NEXT_PUBLIC_APP_URL=https://monitoring.example.com +CORS_ORIGIN=https://monitoring.example.com + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +DATABASE_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres + +# Security +CRON_SECRET=replace-with-random-secret + +# Optional notifications +LIGHTHOUSE_SERVICE_URL=http://backend:5000 +RESEND_API_KEY= +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= diff --git a/devops/docker-compose.prod.yml b/devops/docker-compose.prod.yml new file mode 100644 index 0000000..ad730d9 --- /dev/null +++ b/devops/docker-compose.prod.yml @@ -0,0 +1,70 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: ghcr.io/${GHCR_OWNER}/cloudlense-backend:${IMAGE_TAG:-latest} + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "${BACKEND_PORT:-5000}:5000" + environment: + PORT: 5000 + NODE_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + CORS_ORIGIN: ${CORS_ORIGIN} + CHROME_PATH: /usr/bin/chromium + healthcheck: + test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:5000/health',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 3 + start_period: 30s + + frontend: + image: ghcr.io/${GHCR_OWNER}/cloudlense-frontend:${IMAGE_TAG:-latest} + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${FRONTEND_PORT:-3000}:3000" + environment: + NODE_ENV: production + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} + DATABASE_URL: ${DATABASE_URL} + CORS_ORIGIN: ${CORS_ORIGIN} + CRON_SECRET: ${CRON_SECRET} + LIGHTHOUSE_SERVICE_URL: ${LIGHTHOUSE_SERVICE_URL:-http://backend:5000} + RESEND_API_KEY: ${RESEND_API_KEY:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} + healthcheck: + test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:3000',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + postgres_data: diff --git a/devops/docker-compose.yml b/devops/docker-compose.yml index a379ba7..0b6aee4 100644 --- a/devops/docker-compose.yml +++ b/devops/docker-compose.yml @@ -33,7 +33,7 @@ services: CHROME_PATH: /usr/bin/chromium NODE_ENV: production healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"] + test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:5000/health',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 15s timeout: 10s retries: 3 @@ -55,7 +55,7 @@ services: NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:-} NODE_ENV: production healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"] + test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:3000',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 15s timeout: 10s retries: 3 diff --git a/devops/scripts/deploy-prod.sh b/devops/scripts/deploy-prod.sh new file mode 100644 index 0000000..112abdd --- /dev/null +++ b/devops/scripts/deploy-prod.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Missing required environment variable: $name" >&2 + exit 1 + fi +} + +require_env "GHCR_USERNAME" +require_env "GHCR_READ_TOKEN" +require_env "GHCR_OWNER" +require_env "IMAGE_TAG" + +if [[ ! -f ".env" ]]; then + echo "Missing .env in deployment directory. Create it from devops/.env.production.example." >&2 + exit 1 +fi + +echo "${GHCR_READ_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin + +IMAGE_TAG="${IMAGE_TAG}" GHCR_OWNER="${GHCR_OWNER}" docker compose -f docker-compose.prod.yml --env-file .env pull +IMAGE_TAG="${IMAGE_TAG}" GHCR_OWNER="${GHCR_OWNER}" docker compose -f docker-compose.prod.yml --env-file .env up -d --remove-orphans + +docker compose -f docker-compose.prod.yml --env-file .env ps diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 949fc2c..61eb5af 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -39,6 +39,6 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD node -e "const h=require('http');h.get('http://localhost:3000/api/health',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))" + CMD node -e "const h=require('http');h.get('http://localhost:3000',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))" CMD ["node", "server.js"]