From d263bf0dcb9e8fd7017d71d63d9f4ce17f9c55a8 Mon Sep 17 00:00:00 2001 From: Kiruthiyan Date: Sun, 11 Oct 2026 21:50:20 +0530 Subject: [PATCH 1/2] Add EC2 deployment: Dockerfile, Compose+Caddy, deploy script, health checks Trust proxy in production, graceful shutdown, /api/readyz, deploy runbook and a manual-trigger-friendly GitHub Actions deploy workflow. Co-Authored-By: Claude Sonnet 5.5 --- .gitattributes | 3 + .github/workflows/deploy.yml | 55 ++++++++++++++ README.md | 2 + backend/.dockerignore | 11 +++ backend/.env.example | 14 +++- backend/Dockerfile | 26 +++++++ backend/src/app.ts | 5 ++ backend/src/index.ts | 19 +++++ backend/src/routes/health.ts | 12 +++ deploy/.env.example | 3 + deploy/Caddyfile | 11 +++ deploy/deploy.sh | 37 ++++++++++ deploy/docker-compose.prod.yml | 39 ++++++++++ docs/DEPLOY_EC2.md | 129 +++++++++++++++++++++++++++++++++ 14 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/deploy.yml create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/deploy.sh create mode 100644 deploy/docker-compose.prod.yml create mode 100644 docs/DEPLOY_EC2.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc150fb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +Dockerfile text eol=lf +Caddyfile text eol=lf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..54a2af2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,55 @@ +name: Deploy backend (EC2) + +# Runs only when triggered manually or after CI passes on master. +on: + workflow_dispatch: + push: + branches: [master] + paths: ["backend/**", "deploy/**"] + +concurrency: + group: deploy-backend + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: backend/pnpm-lock.yaml + - name: Typecheck, test and build + working-directory: backend + run: | + pnpm install --frozen-lockfile + pnpm typecheck + pnpm test + pnpm build + + - name: Configure SSH + run: | + install -m 700 -d ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.EC2_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy on EC2 (build, health check, auto-rollback) + run: | + ssh "${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}" \ + "cd ~/SkillPath && git fetch origin master && git reset --hard origin/master && ./deploy/deploy.sh" + + - name: Public health check + run: | + for i in 1 2 3 4 5 6; do + curl -fsS "https://${{ secrets.API_DOMAIN }}/api/healthz" && exit 0 + sleep 10 + done + echo "Public health check failed" && exit 1 diff --git a/README.md b/README.md index cd0e8f9..16fc7a5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ On Windows, Supabase's direct `db.*.supabase.co` host may fail; use the **Sessio **Frontend (Vercel)** — Root directory `frontend`; install `pnpm install --frozen-lockfile`; build `pnpm build`; output `dist`; set `VITE_API_URL` and `VITE_GOOGLE_CLIENT_ID`. +**Backend (AWS EC2)** — Docker Compose + Caddy HTTPS; see [`docs/DEPLOY_EC2.md`](docs/DEPLOY_EC2.md). + **Backend (Render)** — Use [`render.yaml`](render.yaml) or create a Web Service with root `backend`, build `pnpm install --frozen-lockfile && pnpm build`, start `pnpm start`, health check `/api/healthz`. Set `DATABASE_URL`, `JWT_SECRET`, `CORS_ORIGINS` and `APP_BASE_URL`, plus the optional keys above. ## Security diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5c4c990 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.env +.env.* +!.env.example +*.log +*.tsbuildinfo +data +scripts +Dockerfile +.dockerignore diff --git a/backend/.env.example b/backend/.env.example index c4f1b5c..acec783 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,17 +1,25 @@ # Backend - Supabase Postgres (URL-encode special chars in password, e.g. @ -> %40) # Use the Supabase Session pooler URI when the direct host is unavailable: # DATABASE_URL=postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres -DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@db.YOUR_PROJECT.supabase.co:5432/postgres +# EC2 usually has no IPv6, and the direct host is IPv6-only: use the Session pooler URI on EC2. +DATABASE_URL=postgresql://postgres.PROJECT_REF:YOUR_PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres -JWT_SECRET=your-jwt-secret-change-in-production +NODE_ENV=production + +# Generate with: openssl rand -hex 48 +JWT_SECRET=change-me-long-random-secret GEMINI_API_KEY= PORT=5000 +LOG_LEVEL=info +# Number of reverse proxies in front of the API (Caddy = 1). Needed for per-client rate limits. +TRUST_PROXY=1 # Google OAuth 2.0 Client ID (Google Cloud Console -> Credentials), used to verify Google sign-in # credentials on the backend. Required for /auth/google and /auth/google/link. GOOGLE_CLIENT_ID= -# REQUIRED in production: comma-separated frontend origins (e.g. https://your-app.vercel.app). +# REQUIRED in production: comma-separated frontend origins, exact scheme+host, no trailing slash +# (e.g. https://your-app.vercel.app). # If empty, the API reflects any Origin (unsafe for production). CORS_ORIGINS= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0373aa6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1 +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ + +FROM base AS build +RUN pnpm install --frozen-lockfile +COPY tsconfig.json build.mjs ./ +COPY src ./src +RUN pnpm build + +FROM base AS prod-deps +RUN pnpm install --frozen-lockfile --prod + +FROM node:20-alpine AS runtime +ENV NODE_ENV=production PORT=5000 +WORKDIR /app +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +USER node +EXPOSE 5000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||5000)+'/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "dist/index.js"] diff --git a/backend/src/app.ts b/backend/src/app.ts index 8744cd5..964121a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,11 @@ import { logger } from "./lib/logger"; const app = express(); +// Behind a reverse proxy (Caddy/Nginx) the client IP comes from X-Forwarded-For. +// Needed for correct per-client rate limiting. Set TRUST_PROXY=0 to disable. +const trustProxy = Number(process.env.TRUST_PROXY ?? (process.env.NODE_ENV === "production" ? "1" : "0")); +if (trustProxy > 0) app.set("trust proxy", trustProxy); + const corsOrigins = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean); function collectErrorText(err: unknown): string { diff --git a/backend/src/index.ts b/backend/src/index.ts index 9ff685c..04d095f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ import "./load-env.js"; import { createServer } from "http"; import app from "./app"; +import { pool } from "./db"; import { logger } from "./lib/logger"; const port = Number(process.env.PORT ?? "5000"); @@ -14,3 +15,21 @@ const server = createServer(app); server.listen(port, "0.0.0.0", () => { logger.info({ port }, "API server listening"); }); + +// Graceful shutdown so `docker stop` / redeploys finish in-flight requests. +let shuttingDown = false; +function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ signal }, "Shutting down"); + const force = setTimeout(() => process.exit(1), 10_000); + force.unref(); + server.close(() => { + pool + .end() + .catch((err: unknown) => logger.error({ err }, "Error closing DB pool")) + .finally(() => process.exit(0)); + }); +} +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index a75f4f0..8f18708 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -1,9 +1,21 @@ import { Router } from "express"; +import { pool } from "../db"; const router = Router(); +// Liveness: process is up (no external dependencies). Used by Docker/Caddy/CI. router.get("/healthz", (_req, res) => { res.json({ status: "ok" }); }); +// Readiness: also verifies the database connection. +router.get("/readyz", async (_req, res) => { + try { + await pool.query("select 1"); + res.json({ status: "ok", database: "up" }); + } catch { + res.status(503).json({ status: "unavailable", database: "down" }); + } +}); + export default router; diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..f50ccc5 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,3 @@ +# Used by deploy/docker-compose.prod.yml (not the API itself — see backend/.env) +API_DOMAIN=api.example.com +ACME_EMAIL=you@example.com diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..5de0d05 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,11 @@ +{ + email {$ACME_EMAIL} +} + +{$API_DOMAIN} { + encode gzip + reverse_proxy api:5000 { + health_uri /api/healthz + health_interval 30s + } +} diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..e2ed261 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Build and (re)start the stack on the EC2 host, with health check + automatic rollback. +# Run from anywhere on the server: ./deploy/deploy.sh +set -euo pipefail + +cd "$(dirname "$0")/.." +COMPOSE="docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env" +IMAGE="skillpath-api" + +# Keep the currently running image so we can roll back. +if docker image inspect "$IMAGE:latest" >/dev/null 2>&1; then + docker tag "$IMAGE:latest" "$IMAGE:previous" +fi + +echo "==> Building and starting" +$COMPOSE up -d --build --remove-orphans + +echo "==> Waiting for API health" +for i in $(seq 1 30); do + status=$(docker inspect --format '{{.State.Health.Status}}' "$($COMPOSE ps -q api)" 2>/dev/null || echo starting) + if [ "$status" = "healthy" ]; then + echo "API healthy" + docker image prune -f >/dev/null + exit 0 + fi + sleep 5 +done + +echo "!! API did not become healthy. Logs:" +$COMPOSE logs --tail=50 api || true + +if docker image inspect "$IMAGE:previous" >/dev/null 2>&1; then + echo "==> Rolling back to previous image" + docker tag "$IMAGE:previous" "$IMAGE:latest" + $COMPOSE up -d --no-build api +fi +exit 1 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..2d08ef0 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,39 @@ +# Production stack for a single EC2 host: API + Caddy (automatic HTTPS). +# Usage (from repo root): docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env up -d --build +services: + api: + build: ../backend + image: skillpath-api:latest + env_file: ../backend/.env + environment: + NODE_ENV: production + PORT: "5000" + restart: unless-stopped + expose: + - "5000" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + caddy: + image: caddy:2 + restart: unless-stopped + depends_on: + api: + condition: service_healthy + ports: + - "80:80" + - "443:443" + environment: + API_DOMAIN: ${API_DOMAIN:?set API_DOMAIN in deploy/.env} + ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in deploy/.env} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docs/DEPLOY_EC2.md b/docs/DEPLOY_EC2.md new file mode 100644 index 0000000..7635327 --- /dev/null +++ b/docs/DEPLOY_EC2.md @@ -0,0 +1,129 @@ +# Deploy the SkillPath API on AWS EC2 + +Stack on one EC2 host: **Docker Compose** → `api` (Express, port 5000, internal only) + **Caddy** (HTTPS on 80/443, free Let's Encrypt certificate). The database stays on your existing Supabase project; nothing here creates or migrates a database. + +## What you need first + +- A domain/subdomain for the API (e.g. `api.yourdomain.com`) you can point at the server. +- The hosted frontend URL (exact origin, e.g. `https://skillpath.vercel.app`). +- Your existing Supabase **Session pooler** connection string (EC2 normally has no IPv6, and the direct host is IPv6-only). + +## 1. Create the server (manual, in the AWS console) + +- Ubuntu 22.04/24.04, `t3.small` or larger (1 GB RAM is tight for the Docker build), 20 GB disk. +- Allocate and attach an **Elastic IP** (so the DNS record never changes). +- Security group inbound rules: + +| Port | Source | Purpose | +| ---- | ------ | ------- | +| 22 | Your IP only | SSH | +| 80 | 0.0.0.0/0 | HTTP (certificate issuance, redirect) | +| 443 | 0.0.0.0/0 | HTTPS | + +Do **not** open 5000 — the API is only reachable through Caddy. + +- DNS: create an `A` record `api.yourdomain.com` → the Elastic IP. + +## 2. Install Docker + +```bash +ssh -i your-key.pem ubuntu@ +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER && newgrp docker +docker compose version +``` + +## 3. Get the code and configure + +```bash +git clone https://github.com/Kiruthiyan/SkillPath.git ~/SkillPath # use a deploy key / token if private +cd ~/SkillPath + +cp backend/.env.example backend/.env +nano backend/.env +cp deploy/.env.example deploy/.env +nano deploy/.env +``` + +`backend/.env` (use your **existing** values): + +| Variable | Value | +| -------- | ----- | +| `DATABASE_URL` | Supabase Session pooler URI (URL-encode special characters in the password) | +| `JWT_SECRET` | **Keep the current one** so existing logins stay valid; otherwise `openssl rand -hex 48` | +| `CORS_ORIGINS` | `https://your-frontend-url` (comma-separated for several; no trailing slash) | +| `APP_BASE_URL` | `https://your-frontend-url` | +| `GOOGLE_CLIENT_ID` | Same as the frontend's `VITE_GOOGLE_CLIENT_ID` | +| `SMTP_HOST/PORT/USER/PASS/FROM` | Your existing SMTP details (password-reset emails) | +| `GEMINI_API_KEY` | Optional, enables AI features | +| `NODE_ENV`, `PORT`, `TRUST_PROXY` | `production`, `5000`, `1` (leave as in the example) | + +`deploy/.env`: `API_DOMAIN=api.yourdomain.com` and `ACME_EMAIL=you@yourdomain.com`. + +## 4. First deploy + +```bash +./deploy/deploy.sh +``` + +It builds the image, starts the stack (auto-restarts on crash/reboot via `restart: unless-stopped`), waits for the container health check, and rolls back to the previous image if it fails. + +## 5. Verify + +```bash +curl https://api.yourdomain.com/api/healthz # {"status":"ok"} +curl https://api.yourdomain.com/api/readyz # {"status":"ok","database":"up"} +docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env ps +docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env logs -f api +``` + +## 6. Point the frontend at the API + +In the frontend host (e.g. Vercel → Project → Environment Variables): + +``` +VITE_API_URL=https://api.yourdomain.com +VITE_GOOGLE_CLIENT_ID= +``` + +Redeploy the frontend. In Google Cloud Console → Credentials, make sure the frontend origin is in **Authorized JavaScript origins**. Test: register, login, Google sign-in, forgot-password email. + +## Routine operations + +```bash +cd ~/SkillPath && git pull && ./deploy/deploy.sh # update +docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env restart api +``` + +## Rollback + +`deploy.sh` rolls back automatically when the new image is unhealthy. Manual rollback: + +```bash +cd ~/SkillPath +docker tag skillpath-api:previous skillpath-api:latest +docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env up -d --no-build api +# or return to a known-good commit: +git checkout && ./deploy/deploy.sh +``` + +## GitHub Actions deployment (after the manual deploy works) + +[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) runs typecheck, tests and build, SSHes to the server, runs `deploy/deploy.sh` (health check + rollback), then checks the public `/api/healthz`. It triggers on pushes to `master` that touch `backend/**` or `deploy/**`, or manually. + +Create a GitHub **Environment** named `production` and add secrets: + +| Secret | Value | +| ------ | ----- | +| `EC2_HOST` | Elastic IP or domain | +| `EC2_USER` | `ubuntu` | +| `EC2_SSH_KEY` | Private key of a dedicated deploy key (add its public key to `~/.ssh/authorized_keys` on the server) | +| `API_DOMAIN` | `api.yourdomain.com` | + +Tip: require approval on the `production` environment if you want a manual gate. + +## Notes + +- Existing data is untouched. Schema changes (`pnpm db:push`) are **not** run by the deploy; run them deliberately and review first. +- Secrets live only in `backend/.env` on the server (git-ignored). Back it up somewhere safe. +- Logs rotate (10 MB × 3). Free disk with `docker image prune -f` if needed. From 70793befb3126e7109b30f197e3db789040727c7 Mon Sep 17 00:00:00 2001 From: Kiruthiyan Date: Sun, 11 Oct 2026 21:50:58 +0530 Subject: [PATCH 2/2] Make deploy workflow manual-only; mark deploy.sh executable Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy.yml | 6 ++---- deploy/deploy.sh | 0 2 files changed, 2 insertions(+), 4 deletions(-) mode change 100644 => 100755 deploy/deploy.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 54a2af2..ff183da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,11 +1,9 @@ name: Deploy backend (EC2) -# Runs only when triggered manually or after CI passes on master. +# Disabled for now: manual trigger only. After the manual deploy works and the +# secrets are set, add a push trigger on master (paths: backend/**, deploy/**). on: workflow_dispatch: - push: - branches: [master] - paths: ["backend/**", "deploy/**"] concurrency: group: deploy-backend diff --git a/deploy/deploy.sh b/deploy/deploy.sh old mode 100644 new mode 100755