From 3b544f6a254d3be40cc81673a9adf315c1c26bfc Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 20 Mar 2026 18:57:15 -0600 Subject: [PATCH] Add CI/CD pipeline with Gitea Actions and production deployment - Production Dockerfiles: backend (gunicorn + uvicorn workers), frontend (multi-stage Node build + nginx with API proxy) - docker-compose.prod.yml: integrates with VPS nginx-proxy via VIRTUAL_HOST for auto-TLS at fit.cescalante.dev - GitHub Actions workflow (Gitea Actions-compatible): builds images and deploys on push to main via self-hosted runner - Make CORS origins configurable via CORS_ORIGINS env var Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 50 ++++++++++++++++++++++ backend/Dockerfile.prod | 16 +++++++ backend/app/config.py | 6 +++ backend/app/main.py | 7 ++- docker-compose.prod.yml | 82 ++++++++++++++++++++++++++++++++++++ frontend/Dockerfile.prod | 15 +++++++ frontend/nginx-frontend.conf | 26 ++++++++++++ 7 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 backend/Dockerfile.prod create mode 100644 docker-compose.prod.yml create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/nginx-frontend.conf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3d9c137 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy to VPS + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: self-hosted + + steps: + - uses: actions/checkout@v4 + + - name: Write .env.prod + run: | + cat > .env.prod << 'ENVEOF' + POSTGRES_USER=${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB=${{ secrets.POSTGRES_DB }} + OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + SECRET_KEY=${{ secrets.SECRET_KEY }} + VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }} + VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }} + VAPID_MAILTO=${{ secrets.VAPID_MAILTO }} + CORS_ORIGINS=${{ secrets.CORS_ORIGINS }} + VITE_API_URL=${{ secrets.VITE_API_URL }} + ENVEOF + + - name: Build and deploy + run: | + docker compose -f docker-compose.prod.yml --env-file .env.prod build + docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --remove-orphans + + - name: Wait for health + run: | + echo "Waiting for backend..." + for i in $(seq 1 30); do + if docker inspect healthyfit-backend-prod --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + echo "Backend is healthy" + break + fi + sleep 2 + done + + - name: Prune old images + run: docker image prune -f + + - name: Cleanup + if: always() + run: rm -f .env.prod diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..3f32a2b --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "app.main:app", \ + "-k", "uvicorn.workers.UvicornWorker", \ + "-w", "2", \ + "--bind", "0.0.0.0:8000", \ + "--timeout", "120"] diff --git a/backend/app/config.py b/backend/app/config.py index 6ee20e3..9451bd6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,12 @@ class Settings(BaseSettings): OPENAI_API_KEY: str | None = None SECRET_KEY: str = "changethis" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days + CORS_ORIGINS: str = "http://localhost:5173,http://localhost:5174,http://localhost:3000" + VAPID_PRIVATE_KEY: str = "" + VAPID_PUBLIC_KEY: str = "" + VAPID_MAILTO: str = "mailto:admin@example.com" + PUSH_REMINDER_HOUR: int = 9 + PUSH_REMINDER_MINUTE: int = 0 class Config: env_file = ".env" diff --git a/backend/app/main.py b/backend/app/main.py index 6e1f918..5830a20 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,22 +4,27 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.v1.api import api_router +from app.config import settings from app.core.ai_config import configure_dspy from app.db import init_db +from app.models import push_subscription # noqa: F401 — ensures table is created +from app.scheduler import start_scheduler, stop_scheduler @asynccontextmanager async def lifespan(app: FastAPI): init_db() configure_dspy() + start_scheduler() yield + stop_scheduler() app = FastAPI(title="Healthy Fit API", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"], + allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c098bc8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,82 @@ +services: + db: + image: pgvector/pgvector:pg16 + container_name: healthyfit-db-prod + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - healthyfit-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: healthyfit-backend-prod + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + OPENAI_API_KEY: ${OPENAI_API_KEY} + SECRET_KEY: ${SECRET_KEY} + CORS_ORIGINS: ${CORS_ORIGINS} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_MAILTO: ${VAPID_MAILTO} + expose: + - "8000" + networks: + - healthyfit-network + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + VITE_API_URL: ${VITE_API_URL} + container_name: healthyfit-frontend-prod + restart: unless-stopped + environment: + VIRTUAL_HOST: fit.cescalante.dev + LETSENCRYPT_HOST: fit.cescalante.dev + LETSENCRYPT_EMAIL: cescalante2988@gmail.com + expose: + - "80" + networks: + - healthyfit-network + - nginx-prod-network + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + healthyfit-network: + driver: bridge + name: healthyfit-network-prod + nginx-prod-network: + external: true + +volumes: + postgres_data: + name: healthyfit-postgres-prod-data diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..44bc376 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,15 @@ +# Stage 1: Build +FROM node:20-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} +RUN npm run build + +# Stage 2: Serve +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx-frontend.conf b/frontend/nginx-frontend.conf new file mode 100644 index 0000000..77d1921 --- /dev/null +++ b/frontend/nginx-frontend.conf @@ -0,0 +1,26 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API to backend (same docker network) + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # Cache immutable assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +}