# ============================================================================= # quinn.ai — Production nginx configuration (ai.transquinnftw.com) # ============================================================================= # Services: # - Static PWA /var/www/quinn.ai/dist/ (React) # - companion-api http://127.0.0.1:3850 (NestJS HTTP + WebSocket) # # Upstream services (reached over WireGuard wg1 → apricot 10.9.0.2): # - model-boss http://10.9.0.2:8210 (AI routing — OpenAI-compat API) # - chatterbox-tts http://10.9.0.2:8000 (TTS service) # - (ai-core not running — bypassed via claude:* mode on model-boss) # # Auth: nginx basic auth protects everything (single-user dashboard). # htpasswd file: /etc/quinn-ai/htpasswd # # Push fire endpoint: restricted to WireGuard peer (apricot 10.9.0.2). # # Required in /etc/nginx/conf.d/quinn-ai-maps.conf (managed by deploy.sh): # limit_req_zone $binary_remote_addr zone=quinn_ai_req:10m rate=10r/s; # limit_conn_zone $binary_remote_addr zone=quinn_ai_conn:10m; # # TLS: certbot certonly --webroot -d ai.transquinnftw.com # (do NOT use certbot --nginx — Quinn maintains nginx configs manually) # ============================================================================= # HTTP → HTTPS redirect server { listen 80; listen [::]:80; server_name ai.transquinnftw.com; # ACME challenge for Let's Encrypt renewal location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://ai.transquinnftw.com$request_uri; } } # HTTPS — main server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name ai.transquinnftw.com; # TLS ssl_certificate /etc/letsencrypt/live/ai.transquinnftw.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ai.transquinnftw.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_timeout 1d; ssl_session_cache shared:SSL:10m; ssl_session_tickets off; ssl_ecdh_curve X25519:P-256:P-384; # Timeouts client_body_timeout 15s; client_header_timeout 10s; # Security headers add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; # CSP: allow wss: for voice WebSocket, data: for audio blobs add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss://ai.transquinnftw.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer" always; add_header Cross-Origin-Resource-Policy "same-origin" always; add_header X-DNS-Prefetch-Control "off" always; add_header X-Robots-Tag "noindex, nofollow" always; # Connection limits (single-user app behind basic auth — kept generous) limit_conn quinn_ai_conn 50; limit_conn_status 429; # Basic auth — protects everything (single-user private dashboard) auth_basic "Quinn AI"; auth_basic_user_file /etc/quinn-ai/htpasswd; # --------------------------------------------------------------------------- # Push fire endpoint — restricted to apricot wg1 IP (10.9.0.2) only # The coworker-agent on apricot calls this to trigger push notifications. # --------------------------------------------------------------------------- location = /api/push/fire { allow 10.9.0.2; deny all; proxy_pass http://127.0.0.1:3850; proxy_http_version 1.1; 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_connect_timeout 10s; proxy_read_timeout 30s; proxy_send_timeout 15s; proxy_buffering on; proxy_hide_header X-Powered-By; proxy_hide_header Server; } # --------------------------------------------------------------------------- # Push subscription endpoints (VAPID key + subscribe/unsubscribe) # --------------------------------------------------------------------------- location /api/push/ { limit_req zone=quinn_ai_req burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:3850; proxy_http_version 1.1; 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_connect_timeout 10s; proxy_read_timeout 30s; proxy_send_timeout 15s; proxy_buffering on; proxy_hide_header X-Powered-By; proxy_hide_header Server; } # --------------------------------------------------------------------------- # Chat SSE stream (/chat) — must NOT buffer; SSE requires streaming through # --------------------------------------------------------------------------- location = /chat { limit_req zone=quinn_ai_req burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:3850; proxy_http_version 1.1; 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_connect_timeout 10s; proxy_read_timeout 600s; proxy_send_timeout 600s; # SSE: disable buffering so chunks flow immediately to client proxy_buffering off; proxy_cache off; add_header X-Accel-Buffering "no" always; proxy_hide_header X-Powered-By; proxy_hide_header Server; } # --------------------------------------------------------------------------- # Voice WebSocket proxy — long-lived binary PCM streams # CRITICAL: proxy_buffering off — binary PCM frames must not be buffered # --------------------------------------------------------------------------- location /voice/ { limit_conn quinn_ai_conn 10; limit_conn_status 429; proxy_pass http://127.0.0.1:3850; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; 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 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; # CRITICAL — binary PCM must not be buffered proxy_buffering off; proxy_request_buffering off; proxy_hide_header X-Powered-By; proxy_hide_header Server; } # --------------------------------------------------------------------------- # Session, health, and general API endpoints # --------------------------------------------------------------------------- location ~ ^/(session|health|api)(/|$) { limit_req zone=quinn_ai_req burst=50 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:3850; proxy_http_version 1.1; 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_connect_timeout 10s; proxy_read_timeout 60s; proxy_send_timeout 15s; proxy_buffering on; proxy_buffer_size 8k; proxy_buffers 16 8k; proxy_busy_buffers_size 16k; proxy_hide_header X-Powered-By; proxy_hide_header Server; } # --------------------------------------------------------------------------- # Service worker — must not be cached # --------------------------------------------------------------------------- location = /sw.js { root /var/www/quinn.ai/dist; add_header Cache-Control "no-cache, no-store, must-revalidate" always; expires 0; } # --------------------------------------------------------------------------- # SPA entry point — no-cache so updated index.html deploys immediately # --------------------------------------------------------------------------- location = /index.html { root /var/www/quinn.ai/dist; add_header Cache-Control "no-cache, no-store, must-revalidate" always; expires 0; } # Immutable assets (Vite content-hashed) location ~* \.(js|css|woff2?|ttf|eot|svg|ico|png|webp)$ { root /var/www/quinn.ai/dist; expires 1y; add_header Cache-Control "public, immutable"; } # SPA — static files, fallback to index.html for client-side navigation location / { root /var/www/quinn.ai/dist; try_files $uri $uri/ /index.html; expires 1h; add_header Cache-Control "public, no-transform"; } access_log /var/log/nginx/quinn.ai.access.log; error_log /var/log/nginx/quinn.ai.error.log; }