diff --git a/ci/.env.prod.example b/ci/.env.prod.example new file mode 100644 index 0000000..ed258c4 --- /dev/null +++ b/ci/.env.prod.example @@ -0,0 +1,45 @@ +# Public URL (used for CORS, email links, Caddy TLS) +# Use your domain (e.g. https://cv.example.com) or IP for testing (http://123.45.67.89) +PUBLIC_URL=https://cv.example.com +DOMAIN=cv.example.com + +# Database (use strong credentials in production) +POSTGRES_USER=cv +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD +POSTGRES_DB=cv + +# Secrets (generate with: openssl rand -hex 32) +JWT_SECRET=CHANGE_ME_GENERATE_WITH_OPENSSL +ENCRYPTION_KEY=CHANGE_ME_GENERATE_WITH_OPENSSL_64_CHARS + +# JWT Expiry +JWT_ACCESS_TOKEN_EXPIRY=15m +JWT_REFRESH_TOKEN_EXPIRY=7d + +# AI Provider (anthropic or openai — no local llama in production) +AI_PROVIDER=anthropic +ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 + +# Or use OpenAI instead: +# AI_PROVIDER=openai +# OPENAI_API_KEY=sk-... +# OPENAI_MODEL=gpt-4o-mini + +# Email (optional — logs to console if not set) +# RESEND_API_KEY=re_... +# EMAIL_FROM_ADDRESS=noreply@example.com +# EMAIL_FROM_NAME=CV Generator + +# Worker +POLL_INTERVAL_MS=2000 +PDF_TIMEOUT_MS=30000 +HEARTBEAT_DB_INTERVAL_MS=30000 + +# Chromium launch args (comma-separated) +# Default (dev): --no-sandbox,--disable-dev-shm-usage +# Low-memory: --no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions +CHROMIUM_ARGS=--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions + +# project-q (path on the Droplet after setup) +PROJECT_Q_PATH=/opt/cv-generator/project-q diff --git a/ci/Caddyfile b/ci/Caddyfile new file mode 100644 index 0000000..a659637 --- /dev/null +++ b/ci/Caddyfile @@ -0,0 +1,33 @@ +{$DOMAIN:localhost} { + @protected { + not path /health + } + + basic_auth @protected { + {$BASIC_AUTH_USER:admin} {$BASIC_AUTH_HASH} + } + + handle /graphql { + reverse_proxy server:3000 + } + + handle /health { + reverse_proxy server:3000 + } + + handle /api/* { + reverse_proxy server:3000 + } + + handle { + reverse_proxy client:80 + } +} + +docs.{$DOMAIN:localhost} { + basic_auth { + {$BASIC_AUTH_USER:admin} {$BASIC_AUTH_HASH} + } + + reverse_proxy docs:80 +} diff --git a/ci/deploy.sh b/ci/deploy.sh new file mode 100755 index 0000000..ae0739e --- /dev/null +++ b/ci/deploy.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy CV Generator to production +# Run from the repo root: ci/deploy.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$REPO_DIR/.env.prod" +COMPOSE_FILE="$REPO_DIR/docker-compose.prod.yml" + +cd "$REPO_DIR" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: $ENV_FILE not found. Copy ci/.env.prod.example to .env.prod and configure it." + exit 1 +fi + +# Export env vars for Caddy / compose +set -a +source "$ENV_FILE" +set +a + +echo "==> Pulling latest code" +git pull --ff-only + +echo "==> Rebuilding project-q (if needed)" +if [ -d "$PROJECT_Q_PATH" ]; then + (cd "$PROJECT_Q_PATH" && git pull --ff-only && npm install && npm run build) +else + echo "Warning: PROJECT_Q_PATH=$PROJECT_Q_PATH not found. Build may fail." +fi + +echo "==> Regenerating Docker manifests" +sh .docker/copy-manifests.sh + +echo "==> Building images" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build + +echo "==> Running database migrations" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm server \ + sh -c "cd /app/apps/server && npx prisma migrate deploy" + +echo "==> Starting services" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d + +echo "==> Seeding database (idempotent)" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec server \ + node dist/scripts/seed.js + +echo "==> Cleaning up old images" +docker image prune -f + +echo "" +echo "==> Deploy complete!" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps +echo "" +echo "Run 'docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs -f' to watch logs" diff --git a/ci/docker-compose.droplet.yml b/ci/docker-compose.droplet.yml new file mode 100644 index 0000000..ea60865 --- /dev/null +++ b/ci/docker-compose.droplet.yml @@ -0,0 +1,115 @@ +services: + caddy: + image: caddy:2-alpine + environment: + DOMAIN: ${DOMAIN} + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + server: + condition: service_healthy + client: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped + + server: + image: cv-generator-server:latest + environment: + PORT: "3000" + NODE_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m} + JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-CV Generator} + CLIENT_URL: ${PUBLIC_URL} + ALLOWED_ORIGINS: ${PUBLIC_URL} + AI_PROVIDER: ${AI_PROVIDER:-anthropic} + AI_TEMPERATURE: ${AI_TEMPERATURE:-0.1} + AI_MAX_TOKENS: ${AI_MAX_TOKENS:-8192} + AI_TIMEOUT: ${AI_TIMEOUT:-60000} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-5-20250929} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} + PDF_OUTPUT_DIR: /app/pdf-output + depends_on: + db: + condition: service_healthy + volumes: + - worker-output:/app/pdf-output:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + restart: unless-stopped + + client: + image: cv-generator-client:latest + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + + worker: + image: cv-generator-worker:latest + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + QUEUE_SCHEMA: ${QUEUE_SCHEMA:-queue} + QUEUE_NAME: ${QUEUE_NAME:-default} + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-2000} + PDF_OUTPUT_DIR: /app/pdf-output + PDF_TIMEOUT_MS: ${PDF_TIMEOUT_MS:-30000} + HEARTBEAT_FILE_PATH: /tmp/worker-heartbeat + HEARTBEAT_DB_INTERVAL_MS: ${HEARTBEAT_DB_INTERVAL_MS:-30000} + CHROMIUM_ARGS: ${CHROMIUM_ARGS:---no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions} + depends_on: + db: + condition: service_healthy + volumes: + - worker-output:/app/pdf-output + deploy: + resources: + limits: + memory: 512M + healthcheck: + test: ["CMD-SHELL", "find /tmp/worker-heartbeat -mmin -1 | grep -q ."] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped + +volumes: + db-data: + worker-output: + caddy-data: + caddy-config: diff --git a/ci/setup-droplet.sh b/ci/setup-droplet.sh new file mode 100755 index 0000000..121349e --- /dev/null +++ b/ci/setup-droplet.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-time Droplet setup for CV Generator +# Run as root on a fresh Ubuntu 24.04 Droplet: +# curl -sSL | bash +# Or: ssh root@droplet 'bash -s' < ci/setup-droplet.sh + +DEPLOY_DIR="/opt/cv-generator" +DEPLOY_USER="deploy" + +echo "==> Installing Docker" +apt-get update +apt-get install -y ca-certificates curl gnupg +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +echo "==> Configuring firewall" +ufw allow OpenSSH +ufw allow 80/tcp +ufw allow 443/tcp +ufw --force enable + +echo "==> Creating deploy user" +if ! id "$DEPLOY_USER" &>/dev/null; then + adduser --disabled-password --gecos "" "$DEPLOY_USER" + usermod -aG docker "$DEPLOY_USER" +fi + +echo "==> Creating deploy directory" +mkdir -p "$DEPLOY_DIR" +chown "$DEPLOY_USER:$DEPLOY_USER" "$DEPLOY_DIR" + +echo "==> Cloning project-q (build dependency)" +su - "$DEPLOY_USER" -c " + cd $DEPLOY_DIR + if [ ! -d project-q ]; then + git clone https://github.com/riotbyte/project-q.git project-q + fi + cd project-q + npm install + npm run build +" + +echo "==> Cloning cv-generator" +su - "$DEPLOY_USER" -c " + cd $DEPLOY_DIR + if [ ! -d app ]; then + git clone https://github.com/mokkenstorm-dev/cv-generator.git app + fi +" + +echo "==> Creating .env.prod" +if [ ! -f "$DEPLOY_DIR/app/.env.prod" ]; then + cp "$DEPLOY_DIR/app/ci/.env.prod.example" "$DEPLOY_DIR/app/.env.prod" + # Generate secrets + JWT_SECRET=$(openssl rand -hex 32) + ENCRYPTION_KEY=$(openssl rand -hex 32) + DB_PASSWORD=$(openssl rand -hex 16) + sed -i "s|CHANGE_ME_GENERATE_WITH_OPENSSL_64_CHARS|$ENCRYPTION_KEY|" "$DEPLOY_DIR/app/.env.prod" + sed -i "s|CHANGE_ME_GENERATE_WITH_OPENSSL|$JWT_SECRET|" "$DEPLOY_DIR/app/.env.prod" + sed -i "s|CHANGE_ME_STRONG_PASSWORD|$DB_PASSWORD|" "$DEPLOY_DIR/app/.env.prod" + + # Generate basic auth hash (Caddy bcrypt format) + BASIC_AUTH_PASS=$(openssl rand -hex 8) + BASIC_AUTH_HASH=$(docker run --rm caddy:2-alpine caddy hash-password --plaintext "$BASIC_AUTH_PASS") + sed -i "s|CHANGE_ME_CADDY_HASH|$BASIC_AUTH_HASH|" "$DEPLOY_DIR/app/.env.prod" + echo "" + echo " Generated secrets in $DEPLOY_DIR/app/.env.prod" + echo " Basic auth password: $BASIC_AUTH_PASS (user: admin)" + echo " Edit this file to set your domain, AI keys, etc." +fi + +echo "" +echo "==> Setup complete!" +echo "" +echo "Next steps:" +echo " 1. Edit $DEPLOY_DIR/app/.env.prod (set DOMAIN, PUBLIC_URL, API keys)" +echo " 2. Run: cd $DEPLOY_DIR/app && ci/deploy.sh" +echo " 3. Point your DNS A record to this Droplet's IP" +echo "" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0384ab5 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,153 @@ +services: + caddy: + image: caddy:2-alpine + environment: + DOMAIN: ${DOMAIN} + BASIC_AUTH_USER: ${BASIC_AUTH_USER:-admin} + BASIC_AUTH_HASH: ${BASIC_AUTH_HASH} + ports: + - "80:80" + - "443:443" + volumes: + - ./ci/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + server: + condition: service_healthy + client: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped + + server: + build: + context: . + dockerfile: .docker/server.Dockerfile + target: production + additional_contexts: + project-q: ${PROJECT_Q_PATH} + nest-service-locator: ${NEST_SERVICE_LOCATOR_PATH} + environment: + PORT: "3000" + NODE_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m} + JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-CV Generator} + CLIENT_URL: ${PUBLIC_URL} + ALLOWED_ORIGINS: ${PUBLIC_URL} + AI_PROVIDER: ${AI_PROVIDER:-anthropic} + AI_TEMPERATURE: ${AI_TEMPERATURE:-0.1} + AI_MAX_TOKENS: ${AI_MAX_TOKENS:-8192} + AI_TIMEOUT: ${AI_TIMEOUT:-60000} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-5-20250929} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} + PDF_OUTPUT_DIR: /app/pdf-output + depends_on: + db: + condition: service_healthy + volumes: + - worker-output:/app/pdf-output:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + restart: unless-stopped + + client: + build: + context: . + dockerfile: .docker/client.Dockerfile + target: production + args: + VITE_SERVER_URL: ${PUBLIC_URL} + VITE_DOCS_URL: https://docs.${DOMAIN} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + + worker: + build: + context: . + dockerfile: .docker/worker.Dockerfile + target: production + additional_contexts: + project-q: ${PROJECT_Q_PATH} + nest-service-locator: ${NEST_SERVICE_LOCATOR_PATH} + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + QUEUE_SCHEMA: ${QUEUE_SCHEMA:-queue} + QUEUE_NAME: ${QUEUE_NAME:-default} + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-2000} + PDF_OUTPUT_DIR: /app/pdf-output + PDF_TIMEOUT_MS: ${PDF_TIMEOUT_MS:-30000} + HEARTBEAT_FILE_PATH: /tmp/worker-heartbeat + HEARTBEAT_DB_INTERVAL_MS: ${HEARTBEAT_DB_INTERVAL_MS:-30000} + CHROMIUM_ARGS: ${CHROMIUM_ARGS:---no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions} + depends_on: + db: + condition: service_healthy + volumes: + - worker-output:/app/pdf-output + deploy: + resources: + limits: + memory: 512M + healthcheck: + test: ["CMD-SHELL", "find /tmp/worker-heartbeat -mmin -1 | grep -q ."] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped + + docs: + profiles: + - docs + build: + context: . + dockerfile: .docker/docs.Dockerfile + target: production + args: + VITE_CLIENT_URL: ${PUBLIC_URL} + VITE_SERVER_URL: ${PUBLIC_URL} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + +volumes: + db-data: + worker-output: + caddy-data: + caddy-config: