# Default nginx config for Teknav frontend. # Production should use nginx.conf.example with native systemd deployment. # Rate-limit zones — generous enough for normal traffic. limit_req_zone $binary_remote_addr zone=general:10m rate=300r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=300r/m; limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m; server { listen 80 default_server; server_name _; root /opt/teknav/current/dist; index index.html; charset utf-8; sendfile on; tcp_nopush on; tcp_nodelay on; server_tokens off; client_max_body_size 12m; client_body_timeout 30s; client_header_timeout 30s; send_timeout 30s; keepalive_timeout 65s; reset_timedout_connection on; # Compression gzip on; gzip_static on; gzip_vary on; gzip_proxied expired no-cache no-store private auth; gzip_comp_level 6; gzip_buffers 16 8k; gzip_http_version 1.1; gzip_min_length 512; gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/ld+json application/manifest+json application/rss+xml application/atom+xml application/xml image/svg+xml font/ttf font/otf font/woff font/woff2; # Basic security headers — not aggressive. add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # CSP: self-only, plus data: URIs for inline SVG/thumbnails. # 'unsafe-inline' on style-src needed by Vite CSS-in-JS. add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self';" always; # Block dotfiles (.git, .env, etc.) location ~ /\. { deny all; return 404; } # Block operator-only credential notes if present near the static root. location = /ACCOUNTS.md { deny all; return 404; } # Static assets — immutable far-future cache. location /assets/ { access_log off; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } location /fonts/ { access_log off; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } location = /favicon.png { access_log off; add_header Cache-Control "public, max-age=2592000"; } location = /robots.txt { access_log off; add_header Cache-Control "public, max-age=3600"; try_files $uri =404; } location = /sitemap.xml { access_log off; add_header Cache-Control "public, max-age=3600"; try_files $uri =404; } location = /feed.xml { access_log off; add_header Cache-Control "public, max-age=3600"; try_files $uri =404; } # SPA fallback — every unknown path serves index.html. location / { limit_req zone=general burst=200 nodelay; try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } # API — auth endpoints get the tighter zone. location /api/v1/auth/login { limit_req zone=auth burst=10 nodelay; proxy_pass http://127.0.0.1:3010; 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_set_header Connection ""; proxy_read_timeout 30s; proxy_hide_header Server; proxy_hide_header X-Powered-By; } location /api/v1/auth/signup { limit_req zone=auth burst=10 nodelay; proxy_pass http://127.0.0.1:3010; 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_set_header Connection ""; proxy_read_timeout 30s; proxy_hide_header Server; proxy_hide_header X-Powered-By; } location ~ ^/api/(admin/stream|auth/notifications/stream)$ { limit_req zone=api burst=200 nodelay; proxy_pass http://127.0.0.1:3010; 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_set_header Connection ""; proxy_read_timeout 1h; proxy_send_timeout 1h; proxy_buffering off; proxy_cache off; gzip off; proxy_hide_header Server; proxy_hide_header X-Powered-By; } location /api/ { limit_req zone=api burst=200 nodelay; proxy_pass http://127.0.0.1:3010; 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_set_header Connection ""; proxy_read_timeout 60s; proxy_connect_timeout 10s; proxy_buffering on; proxy_buffer_size 16k; proxy_buffers 4 64k; proxy_hide_header Server; proxy_hide_header X-Powered-By; } # Uploaded files — served directly from disk. location /uploads/ { access_log off; alias /var/lib/teknav/uploads/; add_header Cache-Control "public, max-age=2592000"; location ~* \.(php|html|htm|js|jsx|ts|sh|py|env|conf)$ { deny all; return 404; } try_files $uri =404; } # 404 → SPA; API errors pass through as-is. error_page 404 /index.html; error_page 429 @rate_limited; error_page 500 502 503 504 @upstream_error; location @rate_limited { default_type application/json; return 429 '{"error":"too_many_requests","retryAfter":60}'; } location @upstream_error { default_type application/json; return 502 '{"error":"service_unavailable"}'; } }