because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(CVG-7): add production deployment with Caddy basic auth

+489
+45
ci/.env.prod.example
··· 1 + # Public URL (used for CORS, email links, Caddy TLS) 2 + # Use your domain (e.g. https://cv.example.com) or IP for testing (http://123.45.67.89) 3 + PUBLIC_URL=https://cv.example.com 4 + DOMAIN=cv.example.com 5 + 6 + # Database (use strong credentials in production) 7 + POSTGRES_USER=cv 8 + POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD 9 + POSTGRES_DB=cv 10 + 11 + # Secrets (generate with: openssl rand -hex 32) 12 + JWT_SECRET=CHANGE_ME_GENERATE_WITH_OPENSSL 13 + ENCRYPTION_KEY=CHANGE_ME_GENERATE_WITH_OPENSSL_64_CHARS 14 + 15 + # JWT Expiry 16 + JWT_ACCESS_TOKEN_EXPIRY=15m 17 + JWT_REFRESH_TOKEN_EXPIRY=7d 18 + 19 + # AI Provider (anthropic or openai — no local llama in production) 20 + AI_PROVIDER=anthropic 21 + ANTHROPIC_API_KEY=sk-ant-... 22 + # ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 23 + 24 + # Or use OpenAI instead: 25 + # AI_PROVIDER=openai 26 + # OPENAI_API_KEY=sk-... 27 + # OPENAI_MODEL=gpt-4o-mini 28 + 29 + # Email (optional — logs to console if not set) 30 + # RESEND_API_KEY=re_... 31 + # EMAIL_FROM_ADDRESS=noreply@example.com 32 + # EMAIL_FROM_NAME=CV Generator 33 + 34 + # Worker 35 + POLL_INTERVAL_MS=2000 36 + PDF_TIMEOUT_MS=30000 37 + HEARTBEAT_DB_INTERVAL_MS=30000 38 + 39 + # Chromium launch args (comma-separated) 40 + # Default (dev): --no-sandbox,--disable-dev-shm-usage 41 + # Low-memory: --no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions 42 + CHROMIUM_ARGS=--no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions 43 + 44 + # project-q (path on the Droplet after setup) 45 + PROJECT_Q_PATH=/opt/cv-generator/project-q
+33
ci/Caddyfile
··· 1 + {$DOMAIN:localhost} { 2 + @protected { 3 + not path /health 4 + } 5 + 6 + basic_auth @protected { 7 + {$BASIC_AUTH_USER:admin} {$BASIC_AUTH_HASH} 8 + } 9 + 10 + handle /graphql { 11 + reverse_proxy server:3000 12 + } 13 + 14 + handle /health { 15 + reverse_proxy server:3000 16 + } 17 + 18 + handle /api/* { 19 + reverse_proxy server:3000 20 + } 21 + 22 + handle { 23 + reverse_proxy client:80 24 + } 25 + } 26 + 27 + docs.{$DOMAIN:localhost} { 28 + basic_auth { 29 + {$BASIC_AUTH_USER:admin} {$BASIC_AUTH_HASH} 30 + } 31 + 32 + reverse_proxy docs:80 33 + }
+58
ci/deploy.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # Deploy CV Generator to production 5 + # Run from the repo root: ci/deploy.sh 6 + 7 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 + REPO_DIR="$(dirname "$SCRIPT_DIR")" 9 + ENV_FILE="$REPO_DIR/.env.prod" 10 + COMPOSE_FILE="$REPO_DIR/docker-compose.prod.yml" 11 + 12 + cd "$REPO_DIR" 13 + 14 + if [ ! -f "$ENV_FILE" ]; then 15 + echo "Error: $ENV_FILE not found. Copy ci/.env.prod.example to .env.prod and configure it." 16 + exit 1 17 + fi 18 + 19 + # Export env vars for Caddy / compose 20 + set -a 21 + source "$ENV_FILE" 22 + set +a 23 + 24 + echo "==> Pulling latest code" 25 + git pull --ff-only 26 + 27 + echo "==> Rebuilding project-q (if needed)" 28 + if [ -d "$PROJECT_Q_PATH" ]; then 29 + (cd "$PROJECT_Q_PATH" && git pull --ff-only && npm install && npm run build) 30 + else 31 + echo "Warning: PROJECT_Q_PATH=$PROJECT_Q_PATH not found. Build may fail." 32 + fi 33 + 34 + echo "==> Regenerating Docker manifests" 35 + sh .docker/copy-manifests.sh 36 + 37 + echo "==> Building images" 38 + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build 39 + 40 + echo "==> Running database migrations" 41 + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm server \ 42 + sh -c "cd /app/apps/server && npx prisma migrate deploy" 43 + 44 + echo "==> Starting services" 45 + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d 46 + 47 + echo "==> Seeding database (idempotent)" 48 + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec server \ 49 + node dist/scripts/seed.js 50 + 51 + echo "==> Cleaning up old images" 52 + docker image prune -f 53 + 54 + echo "" 55 + echo "==> Deploy complete!" 56 + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps 57 + echo "" 58 + echo "Run 'docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs -f' to watch logs"
+115
ci/docker-compose.droplet.yml
··· 1 + services: 2 + caddy: 3 + image: caddy:2-alpine 4 + environment: 5 + DOMAIN: ${DOMAIN} 6 + ports: 7 + - "80:80" 8 + - "443:443" 9 + volumes: 10 + - ./Caddyfile:/etc/caddy/Caddyfile:ro 11 + - caddy-data:/data 12 + - caddy-config:/config 13 + depends_on: 14 + server: 15 + condition: service_healthy 16 + client: 17 + condition: service_healthy 18 + restart: unless-stopped 19 + 20 + db: 21 + image: postgres:16-alpine 22 + environment: 23 + POSTGRES_USER: ${POSTGRES_USER} 24 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 25 + POSTGRES_DB: ${POSTGRES_DB} 26 + volumes: 27 + - db-data:/var/lib/postgresql/data 28 + healthcheck: 29 + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 30 + interval: 10s 31 + timeout: 5s 32 + retries: 3 33 + restart: unless-stopped 34 + 35 + server: 36 + image: cv-generator-server:latest 37 + environment: 38 + PORT: "3000" 39 + NODE_ENV: production 40 + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} 41 + JWT_SECRET: ${JWT_SECRET} 42 + JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m} 43 + JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d} 44 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} 45 + RESEND_API_KEY: ${RESEND_API_KEY:-} 46 + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} 47 + EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-CV Generator} 48 + CLIENT_URL: ${PUBLIC_URL} 49 + ALLOWED_ORIGINS: ${PUBLIC_URL} 50 + AI_PROVIDER: ${AI_PROVIDER:-anthropic} 51 + AI_TEMPERATURE: ${AI_TEMPERATURE:-0.1} 52 + AI_MAX_TOKENS: ${AI_MAX_TOKENS:-8192} 53 + AI_TIMEOUT: ${AI_TIMEOUT:-60000} 54 + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} 55 + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-5-20250929} 56 + OPENAI_API_KEY: ${OPENAI_API_KEY:-} 57 + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} 58 + PDF_OUTPUT_DIR: /app/pdf-output 59 + depends_on: 60 + db: 61 + condition: service_healthy 62 + volumes: 63 + - worker-output:/app/pdf-output:ro 64 + healthcheck: 65 + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] 66 + interval: 15s 67 + timeout: 5s 68 + retries: 3 69 + start_period: 30s 70 + restart: unless-stopped 71 + 72 + client: 73 + image: cv-generator-client:latest 74 + healthcheck: 75 + test: ["CMD", "curl", "-f", "http://localhost:80"] 76 + interval: 15s 77 + timeout: 5s 78 + retries: 3 79 + start_period: 5s 80 + restart: unless-stopped 81 + 82 + worker: 83 + image: cv-generator-worker:latest 84 + environment: 85 + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} 86 + QUEUE_SCHEMA: ${QUEUE_SCHEMA:-queue} 87 + QUEUE_NAME: ${QUEUE_NAME:-default} 88 + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-2000} 89 + PDF_OUTPUT_DIR: /app/pdf-output 90 + PDF_TIMEOUT_MS: ${PDF_TIMEOUT_MS:-30000} 91 + HEARTBEAT_FILE_PATH: /tmp/worker-heartbeat 92 + HEARTBEAT_DB_INTERVAL_MS: ${HEARTBEAT_DB_INTERVAL_MS:-30000} 93 + CHROMIUM_ARGS: ${CHROMIUM_ARGS:---no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions} 94 + depends_on: 95 + db: 96 + condition: service_healthy 97 + volumes: 98 + - worker-output:/app/pdf-output 99 + deploy: 100 + resources: 101 + limits: 102 + memory: 512M 103 + healthcheck: 104 + test: ["CMD-SHELL", "find /tmp/worker-heartbeat -mmin -1 | grep -q ."] 105 + interval: 30s 106 + timeout: 5s 107 + retries: 3 108 + start_period: 15s 109 + restart: unless-stopped 110 + 111 + volumes: 112 + db-data: 113 + worker-output: 114 + caddy-data: 115 + caddy-config:
+85
ci/setup-droplet.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # One-time Droplet setup for CV Generator 5 + # Run as root on a fresh Ubuntu 24.04 Droplet: 6 + # curl -sSL <raw-url> | bash 7 + # Or: ssh root@droplet 'bash -s' < ci/setup-droplet.sh 8 + 9 + DEPLOY_DIR="/opt/cv-generator" 10 + DEPLOY_USER="deploy" 11 + 12 + echo "==> Installing Docker" 13 + apt-get update 14 + apt-get install -y ca-certificates curl gnupg 15 + install -m 0755 -d /etc/apt/keyrings 16 + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 17 + chmod a+r /etc/apt/keyrings/docker.gpg 18 + 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 19 + apt-get update 20 + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 21 + 22 + echo "==> Configuring firewall" 23 + ufw allow OpenSSH 24 + ufw allow 80/tcp 25 + ufw allow 443/tcp 26 + ufw --force enable 27 + 28 + echo "==> Creating deploy user" 29 + if ! id "$DEPLOY_USER" &>/dev/null; then 30 + adduser --disabled-password --gecos "" "$DEPLOY_USER" 31 + usermod -aG docker "$DEPLOY_USER" 32 + fi 33 + 34 + echo "==> Creating deploy directory" 35 + mkdir -p "$DEPLOY_DIR" 36 + chown "$DEPLOY_USER:$DEPLOY_USER" "$DEPLOY_DIR" 37 + 38 + echo "==> Cloning project-q (build dependency)" 39 + su - "$DEPLOY_USER" -c " 40 + cd $DEPLOY_DIR 41 + if [ ! -d project-q ]; then 42 + git clone https://github.com/riotbyte/project-q.git project-q 43 + fi 44 + cd project-q 45 + npm install 46 + npm run build 47 + " 48 + 49 + echo "==> Cloning cv-generator" 50 + su - "$DEPLOY_USER" -c " 51 + cd $DEPLOY_DIR 52 + if [ ! -d app ]; then 53 + git clone https://github.com/mokkenstorm-dev/cv-generator.git app 54 + fi 55 + " 56 + 57 + echo "==> Creating .env.prod" 58 + if [ ! -f "$DEPLOY_DIR/app/.env.prod" ]; then 59 + cp "$DEPLOY_DIR/app/ci/.env.prod.example" "$DEPLOY_DIR/app/.env.prod" 60 + # Generate secrets 61 + JWT_SECRET=$(openssl rand -hex 32) 62 + ENCRYPTION_KEY=$(openssl rand -hex 32) 63 + DB_PASSWORD=$(openssl rand -hex 16) 64 + sed -i "s|CHANGE_ME_GENERATE_WITH_OPENSSL_64_CHARS|$ENCRYPTION_KEY|" "$DEPLOY_DIR/app/.env.prod" 65 + sed -i "s|CHANGE_ME_GENERATE_WITH_OPENSSL|$JWT_SECRET|" "$DEPLOY_DIR/app/.env.prod" 66 + sed -i "s|CHANGE_ME_STRONG_PASSWORD|$DB_PASSWORD|" "$DEPLOY_DIR/app/.env.prod" 67 + 68 + # Generate basic auth hash (Caddy bcrypt format) 69 + BASIC_AUTH_PASS=$(openssl rand -hex 8) 70 + BASIC_AUTH_HASH=$(docker run --rm caddy:2-alpine caddy hash-password --plaintext "$BASIC_AUTH_PASS") 71 + sed -i "s|CHANGE_ME_CADDY_HASH|$BASIC_AUTH_HASH|" "$DEPLOY_DIR/app/.env.prod" 72 + echo "" 73 + echo " Generated secrets in $DEPLOY_DIR/app/.env.prod" 74 + echo " Basic auth password: $BASIC_AUTH_PASS (user: admin)" 75 + echo " Edit this file to set your domain, AI keys, etc." 76 + fi 77 + 78 + echo "" 79 + echo "==> Setup complete!" 80 + echo "" 81 + echo "Next steps:" 82 + echo " 1. Edit $DEPLOY_DIR/app/.env.prod (set DOMAIN, PUBLIC_URL, API keys)" 83 + echo " 2. Run: cd $DEPLOY_DIR/app && ci/deploy.sh" 84 + echo " 3. Point your DNS A record to this Droplet's IP" 85 + echo ""
+153
docker-compose.prod.yml
··· 1 + services: 2 + caddy: 3 + image: caddy:2-alpine 4 + environment: 5 + DOMAIN: ${DOMAIN} 6 + BASIC_AUTH_USER: ${BASIC_AUTH_USER:-admin} 7 + BASIC_AUTH_HASH: ${BASIC_AUTH_HASH} 8 + ports: 9 + - "80:80" 10 + - "443:443" 11 + volumes: 12 + - ./ci/Caddyfile:/etc/caddy/Caddyfile:ro 13 + - caddy-data:/data 14 + - caddy-config:/config 15 + depends_on: 16 + server: 17 + condition: service_healthy 18 + client: 19 + condition: service_healthy 20 + restart: unless-stopped 21 + 22 + db: 23 + image: postgres:16-alpine 24 + environment: 25 + POSTGRES_USER: ${POSTGRES_USER} 26 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 27 + POSTGRES_DB: ${POSTGRES_DB} 28 + volumes: 29 + - db-data:/var/lib/postgresql/data 30 + healthcheck: 31 + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 32 + interval: 10s 33 + timeout: 5s 34 + retries: 3 35 + restart: unless-stopped 36 + 37 + server: 38 + build: 39 + context: . 40 + dockerfile: .docker/server.Dockerfile 41 + target: production 42 + additional_contexts: 43 + project-q: ${PROJECT_Q_PATH} 44 + nest-service-locator: ${NEST_SERVICE_LOCATOR_PATH} 45 + environment: 46 + PORT: "3000" 47 + NODE_ENV: production 48 + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} 49 + JWT_SECRET: ${JWT_SECRET} 50 + JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m} 51 + JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d} 52 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} 53 + RESEND_API_KEY: ${RESEND_API_KEY:-} 54 + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} 55 + EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-CV Generator} 56 + CLIENT_URL: ${PUBLIC_URL} 57 + ALLOWED_ORIGINS: ${PUBLIC_URL} 58 + AI_PROVIDER: ${AI_PROVIDER:-anthropic} 59 + AI_TEMPERATURE: ${AI_TEMPERATURE:-0.1} 60 + AI_MAX_TOKENS: ${AI_MAX_TOKENS:-8192} 61 + AI_TIMEOUT: ${AI_TIMEOUT:-60000} 62 + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} 63 + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-5-20250929} 64 + OPENAI_API_KEY: ${OPENAI_API_KEY:-} 65 + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} 66 + PDF_OUTPUT_DIR: /app/pdf-output 67 + depends_on: 68 + db: 69 + condition: service_healthy 70 + volumes: 71 + - worker-output:/app/pdf-output:ro 72 + healthcheck: 73 + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] 74 + interval: 15s 75 + timeout: 5s 76 + retries: 3 77 + start_period: 30s 78 + restart: unless-stopped 79 + 80 + client: 81 + build: 82 + context: . 83 + dockerfile: .docker/client.Dockerfile 84 + target: production 85 + args: 86 + VITE_SERVER_URL: ${PUBLIC_URL} 87 + VITE_DOCS_URL: https://docs.${DOMAIN} 88 + healthcheck: 89 + test: ["CMD", "curl", "-f", "http://localhost:80"] 90 + interval: 15s 91 + timeout: 5s 92 + retries: 3 93 + start_period: 5s 94 + restart: unless-stopped 95 + 96 + worker: 97 + build: 98 + context: . 99 + dockerfile: .docker/worker.Dockerfile 100 + target: production 101 + additional_contexts: 102 + project-q: ${PROJECT_Q_PATH} 103 + nest-service-locator: ${NEST_SERVICE_LOCATOR_PATH} 104 + environment: 105 + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} 106 + QUEUE_SCHEMA: ${QUEUE_SCHEMA:-queue} 107 + QUEUE_NAME: ${QUEUE_NAME:-default} 108 + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-2000} 109 + PDF_OUTPUT_DIR: /app/pdf-output 110 + PDF_TIMEOUT_MS: ${PDF_TIMEOUT_MS:-30000} 111 + HEARTBEAT_FILE_PATH: /tmp/worker-heartbeat 112 + HEARTBEAT_DB_INTERVAL_MS: ${HEARTBEAT_DB_INTERVAL_MS:-30000} 113 + CHROMIUM_ARGS: ${CHROMIUM_ARGS:---no-sandbox,--disable-dev-shm-usage,--disable-gpu,--single-process,--disable-extensions} 114 + depends_on: 115 + db: 116 + condition: service_healthy 117 + volumes: 118 + - worker-output:/app/pdf-output 119 + deploy: 120 + resources: 121 + limits: 122 + memory: 512M 123 + healthcheck: 124 + test: ["CMD-SHELL", "find /tmp/worker-heartbeat -mmin -1 | grep -q ."] 125 + interval: 30s 126 + timeout: 5s 127 + retries: 3 128 + start_period: 15s 129 + restart: unless-stopped 130 + 131 + docs: 132 + profiles: 133 + - docs 134 + build: 135 + context: . 136 + dockerfile: .docker/docs.Dockerfile 137 + target: production 138 + args: 139 + VITE_CLIENT_URL: ${PUBLIC_URL} 140 + VITE_SERVER_URL: ${PUBLIC_URL} 141 + healthcheck: 142 + test: ["CMD", "curl", "-f", "http://localhost:80"] 143 + interval: 15s 144 + timeout: 5s 145 + retries: 3 146 + start_period: 5s 147 + restart: unless-stopped 148 + 149 + volumes: 150 + db-data: 151 + worker-output: 152 + caddy-data: 153 + caddy-config: