Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.sh text eol=lf
Dockerfile text eol=lf
Caddyfile text eol=lf
53 changes: 53 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Deploy backend (EC2)

# 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:

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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
dist
.env
.env.*
!.env.example
*.log
*.tsbuildinfo
data
scripts
Dockerfile
.dockerignore
14 changes: 11 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -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=

Expand Down
26 changes: 26 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
5 changes: 5 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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"));
12 changes: 12 additions & 0 deletions backend/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
email {$ACME_EMAIL}
}

{$API_DOMAIN} {
encode gzip
reverse_proxy api:5000 {
health_uri /api/healthz
health_interval 30s
}
}
37 changes: 37 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions deploy/docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -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:
Loading
Loading