# Teknav production nginx — native systemd starter template. # Install as /etc/nginx/sites-available/teknav and symlink to sites-enabled. # Rate limits — generous by default. Tighten if you see abuse. 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; # ── HTTP → HTTPS redirect ─────────────────────────────────────────────────── server { listen 80; listen [::]:80; server_name teknav.ir www.teknav.ir; server_tokens off; return 301 https://www.teknav.ir$request_uri; } # ── HTTPS vhost ───────────────────────────────────────────────────────────── server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name teknav.ir www.teknav.ir; if ($host = teknav.ir) { return 301 https://www.teknav.ir$request_uri; } # TLS — ssl-certs/ mounted at /etc/nginx/ssl (read-only). ssl_certificate /etc/nginx/ssl/_.teknav.ir-fullchain.pem; ssl_certificate_key /etc/nginx/ssl/_.teknav.ir-privateKey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; 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; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; resolver 1.1.1.1 8.8.8.8 valid=300s; resolver_timeout 5s; server_tokens off; root /opt/teknav/current/dist; index index.html; charset utf-8; sendfile on; tcp_nopush on; tcp_nodelay on; 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; # Serve pre-compressed *.br alongside *.gz at server scope so /assets/ (JS, CSS, # icon SVGs), /fonts/, and HTML inherit it — not just the per-location blocks below. brotli_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; # ── HSTS (1 week — extend to 1 year after you're confident) ───────────── add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # ── Security headers ───────────────────────────────────────────────────── 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. 'unsafe-inline' on style-src required 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 even if copied beside the app. 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; } # embed.html served with frame-ancestors * so widgets can be iframed cross-origin. # Exact match ensures correct headers survive nginx's internal try_files redirect. location = /embed.html { add_header X-Content-Type-Options "nosniff" always; add_header Content-Security-Policy "frame-ancestors *;" always; add_header Cache-Control "no-cache" always; try_files $uri =404; } # Embed widgets — allow cross-origin framing, serve embed.html as SPA fallback location /embed/ { limit_req zone=general burst=200 nodelay; gzip_static on; brotli_static on; add_header X-Content-Type-Options "nosniff" always; add_header Content-Security-Policy "frame-ancestors *;" always; add_header Cache-Control "no-cache" always; try_files $uri $uri/ /embed.html; } # Phase 15: Web Stories, Comparison pages, Tech Directory — pre-rendered static dirs location ~ ^/(stories|compare|directory)/([^/]+?)/?$ { gzip_static on; brotli_static on; try_files /$1/$2/index.html /index.html; } # Phase 15: listing pages for stories, compare, directory location ~ ^/(stories|compare|directory)/?$ { gzip_static on; brotli_static on; try_files /$1/index.html /index.html; } # SPA fallback location / { limit_req zone=general burst=200 nodelay; try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } # Auth endpoints — tighter rate limit 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; } # SSE streams: disable buffering/compression so events arrive immediately. 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; } # General API 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 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. 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"}'; } }