# Production-Grade Nginx Configuration: 2025 Best Practices **Complete guide for modern full-stack applications (FastAPI + Frontend) with emphasis on WebSocket support, configuration organization, and dev/prod architectures.** ## Table of Contents 1. [Configuration File Organization](#configuration-file-organization) 2. [WebSocket Proxying](#websocket-proxying) 3. [Shared Configuration (http.conf)](#shared-configuration-httpconf) 4. [Development Configuration](#development-configuration-devnginx) 5. [Production Configuration](#production-configuration-prodnginx) 6. [Performance Optimization](#performance-optimization) 7. [Security Headers](#security-headers) 8. [Static File Serving](#static-file-serving-production) 9. [Vite Dev Server Integration](#vite-dev-server-integration-development) 10. [Load Balancing & Upstreams](#load-balancing--upstreams) 11. [Rate Limiting](#rate-limiting) 12. [Logging & Monitoring](#logging--monitoring) 13. [Complete Configuration Examples](#complete-configuration-examples) --- ## Configuration File Organization ### Recommended Structure ``` conf/nginx/ ├── http.conf # Shared: upstreams, maps, global http settings ├── dev.nginx # Full nginx.conf for development └── prod.nginx # Full nginx.conf for production ``` ### What Goes Where **http.conf (Shared Configurations)**: - Upstream definitions (backend, frontend servers) - Map directives for WebSocket connection upgrades - Shared rate limit zones - Common proxy settings - MIME type definitions - Log formats **dev.nginx (Development-Specific)**: - Full nginx.conf structure - Includes http.conf - Proxies to Vite dev server (port 5173) - Proxies API/WebSocket to backend (port 8000) - Verbose logging - CORS permissive settings - No caching - No SSL (typically) **prod.nginx (Production-Specific)**: - Full nginx.conf structure - Includes http.conf - Serves static files from `/usr/share/nginx/html` - Proxies API/WebSocket to Gunicorn workers - SSL/TLS configuration - Security headers - Gzip/Brotli compression - Static file caching - Error logging only --- ## WebSocket Proxying WebSocket connections require explicit handling because the "Upgrade" and "Connection" headers are hop-by-hop and not automatically passed to the proxied server. The modern approach uses a `map` directive to handle connections conditionally. ### Core WebSocket Configuration Pattern ```nginx # In http.conf (shared configuration) map $http_upgrade $connection_upgrade { default upgrade; '' close; } ``` This sophisticated approach sets the Connection header to "close" when there's no Upgrade header, and to "upgrade" when WebSocket upgrade is requested. ### WebSocket Location Block ```nginx location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; # WebSocket-specific headers proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Standard proxy headers 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; # Timeout settings for long-lived connections proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_connect_timeout 75s; # Disable buffering for real-time data proxy_buffering off; proxy_cache_bypass $http_upgrade; } ``` ### Critical WebSocket Settings Explained For WebSocket session persistence with multiple backend servers, use `ip_hash` to ensure clients always connect to the same backend: ```nginx upstream websocket_backend { ip_hash; # Session persistence server backend1:8000; server backend2:8000; server backend3:8000; } ``` **Timeout Configuration**: By default, connections close if the proxied server doesn't transmit data within 60 seconds. For WebSockets: - `proxy_read_timeout`: Set to 3600s (1 hour) or higher - `proxy_send_timeout`: Set to 3600s (1 hour) or higher - `proxy_connect_timeout`: Usually 75s is sufficient --- ## Shared Configuration (http.conf) This file contains settings used by both dev and prod environments. ```nginx # conf/nginx/http.conf # WebSocket upgrade handling map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Upstream definitions upstream backend { # Development: single uvicorn instance # Production: multiple gunicorn workers (override in prod.nginx) server backend:8000 max_fails=3 fail_timeout=30s; keepalive 32; # Connection pooling } upstream frontend_dev { # Only used in development server frontend:5173; } # Custom log format with timing information log_format main_timed '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' 'rt=$request_time uct="$upstream_connect_time" ' 'uht="$upstream_header_time" urt="$upstream_response_time"'; # Rate limit zones limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s; # Connection limits limit_conn_zone $binary_remote_addr zone=conn_limit:10m; # Common proxy settings 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; ``` --- ## Development Configuration (dev.nginx) Full nginx.conf optimized for local development with hot module replacement. ```nginx # conf/nginx/dev.nginx user nginx; worker_processes 1; # Single worker sufficient for dev error_log /var/log/nginx/error.log debug; # Verbose logging pid /var/run/nginx.pid; events { worker_connections 1024; use epoll; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Include shared configuration include /etc/nginx/http.conf; # Development-specific settings access_log /var/log/nginx/access.log main_timed; sendfile off; # Disable for file system changes tcp_nopush off; tcp_nodelay on; keepalive_timeout 65; # Disable caching in development add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; # CORS permissive for development add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; server { listen 80; server_name localhost; # Handle preflight requests if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } # API routes to backend location /api/ { limit_req zone=api_limit burst=20 nodelay; limit_conn conn_limit 10; proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; # Proxy buffering settings proxy_buffering off; proxy_request_buffering off; } # WebSocket route location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $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 3600s; proxy_send_timeout 3600s; proxy_buffering off; } # Vite dev server (with HMR WebSocket support) location / { proxy_pass http://frontend_dev; proxy_http_version 1.1; # Required for Vite HMR proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; # Vite-specific timeout proxy_read_timeout 60s; proxy_buffering off; } # Health check endpoint location /health { access_log off; return 200 "healthy\n"; add_header Content-Type text/plain; } } } ``` ### Key Development Features: 1. **Verbose Logging**: `error_log debug` for troubleshooting 2. **No Caching**: Ensures fresh content on every request 3. **Permissive CORS**: Allows frontend to call backend freely 4. **HMR Support**: WebSocket headers (Upgrade and Connection) are required for Vite's Hot Module Replacement to function properly 5. **Disabled Optimizations**: `sendfile off` to catch file changes immediately --- ## Production Configuration (prod.nginx) Optimized for performance, security, and reliability. ```nginx # conf/nginx/prod.nginx user nginx; worker_processes auto; # One per CPU core worker_rlimit_nofile 100000; error_log /var/log/nginx/error.log warn; # Only warnings and errors pid /var/run/nginx.pid; events { worker_connections 4096; # High concurrency support use epoll; # Efficient connection handling on Linux multi_accept on; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Include shared configuration include /etc/nginx/http.conf; # Logging with buffering access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s; # Performance optimizations sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; keepalive_requests 100; types_hash_max_size 2048; server_tokens off; # Hide nginx version # File cache open_file_cache max=10000 inactive=20s; open_file_cache_valid 30s; open_file_cache_min_uses 2; open_file_cache_errors on; # Buffer sizes client_body_buffer_size 128k; client_header_buffer_size 16k; client_max_body_size 10m; large_client_header_buffers 4 16k; # Timeouts client_body_timeout 12s; client_header_timeout 12s; send_timeout 10s; # Gzip compression gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; gzip_disable "msie6"; gzip_min_length 256; # Redirect HTTP to HTTPS server { listen 80; server_name yourdomain.com; return 301 https://$host$request_uri; } # Main HTTPS server server { listen 443 ssl http2; server_name yourdomain.com; # SSL configuration ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/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'; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:50m; ssl_session_timeout 1d; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; # Content Security Policy add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss://yourdomain.com" always; # Root directory for static files root /usr/share/nginx/html; index index.html; # API routes with rate limiting location /api/ { limit_req zone=api_limit burst=20 nodelay; limit_conn conn_limit 20; proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; # Proxy buffering optimized for API proxy_buffering on; proxy_buffers 8 24k; proxy_buffer_size 2k; # Timeouts proxy_connect_timeout 75s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Auth endpoints with stricter rate limiting location /api/auth/ { limit_req zone=auth_limit burst=5 nodelay; proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; } # WebSocket route location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $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 3600s; proxy_send_timeout 3600s; proxy_connect_timeout 75s; proxy_buffering off; } # Static files with aggressive caching location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } location ~* \.(css|js)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } location ~* \.(woff|woff2|ttf|eot|otf)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # SPA fallback - all non-matching routes to index.html location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, must-revalidate"; } # Health check location /health { access_log off; return 200 "healthy\n"; add_header Content-Type text/plain; } # Deny access to hidden files location ~ /\. { deny all; access_log off; log_not_found off; } } } ``` --- ## Performance Optimization ### Worker Configuration Running one worker process per CPU core works well in most cases, and setting worker_processes to auto achieves this: ```nginx worker_processes auto; worker_rlimit_nofile 100000; # File descriptor limit events { worker_connections 4096; use epoll; # Most efficient on Linux multi_accept on; # Accept multiple connections at once } ``` ### Connection and Request Handling The default worker_connections is 512, but most systems can support higher values. The optimal setting depends on server resources and traffic patterns. **Formula**: `max_clients = worker_processes * worker_connections` ### Buffer Optimization If buffer sizes are too low, Nginx will write to temporary files, causing excessive disk I/O: ```nginx client_body_buffer_size 128k; client_header_buffer_size 16k; client_max_body_size 10m; large_client_header_buffers 4 16k; ``` ### Keepalive Connections Keepalive connections reduce CPU and network overhead by keeping connections open longer: ```nginx keepalive_timeout 65; keepalive_requests 100; upstream backend { server backend:8000; keepalive 32; # Idle connections to upstream } ``` ### Sendfile and TCP Optimizations The sendfile() system call enables zero-copy data transfer, speeding up TCP transmissions without consuming CPU cycles: ```nginx sendfile on; tcp_nopush on; # Send headers in one packet tcp_nodelay on; # Disable Nagle's algorithm ``` ### File Caching ```nginx open_file_cache max=10000 inactive=20s; open_file_cache_valid 30s; open_file_cache_min_uses 2; open_file_cache_errors on; ``` ### Compression ```nginx gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; # Balance between CPU and compression gzip_min_length 256; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; ``` **Important**: Don't increase compression level too high, as it costs CPU effort without proportional throughput gains. --- ## Security Headers ### Essential Security Headers (2025) Security headers are levers that slash XSS risk, lock browsers to HTTPS, tame third-party scripts, and protect users without touching app code. ```nginx # HTTP Strict Transport Security (HSTS) add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # Prevent clickjacking add_header X-Frame-Options "SAMEORIGIN" always; # Prevent MIME sniffing add_header X-Content-Type-Options "nosniff" always; # XSS Protection (legacy but still useful) add_header X-XSS-Protection "1; mode=block" always; # Referrer Policy add_header Referrer-Policy "strict-origin-when-cross-origin" always; # Disable dangerous browser features add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; ``` ### Content Security Policy (CSP) CSP controls where scripts, styles, images, frames, and connections can load from. Start with a restrictive policy: ```nginx add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss://yourdomain.com" always; ``` **Best Practice**: Roll out CSP in Report-Only mode first, sending violation reports to an endpoint you control: ```nginx # Test mode add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always; ``` ### HSTS Explained HSTS forces browsers to only use HTTPS by caching this policy for the max-age period. The `includeSubDomains` directive applies the policy to all subdomains. **Preloading**: To add your domain to the browser preload list, include "preload" in the header and submit to hstspreload.org. This is a one-way decision—removal is difficult. ```nginx add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; ``` ### SSL/TLS Best Practices ```nginx 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'; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:50m; ssl_session_timeout 1d; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; ``` --- ## Static File Serving (Production) ### Cache Headers Strategy Use Cache-Control on static files so that CDNs and browsers can cache them effectively. The modern approach favors `Cache-Control` over `Expires`. ```nginx # Images, fonts, media - long cache location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf|mp4|mp3|ogg|webm)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # CSS and JavaScript - long cache with versioning location ~* \.(css|js)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # HTML files - no caching location ~* \.html$ { expires -1; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } ``` ### Cache Busting When files are updated, use cache-busting by appending version numbers to filenames (e.g., `style.css?v=2` or `style.v2.css`). ### SPA Routing For Single Page Applications, route all non-file requests to index.html: ```nginx location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, must-revalidate"; } ``` --- ## Vite Dev Server Integration (Development) ### HMR WebSocket Support Vite's HMR requires WebSocket support with Upgrade and Connection headers set for the WebSocket connection to function: ```nginx location / { proxy_pass http://frontend:5173; proxy_http_version 1.1; # Critical for Vite HMR proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; # Timeouts proxy_read_timeout 60s; proxy_buffering off; } ``` ### Vite HMR Path Handling If Vite uses a custom HMR path: ```nginx location ~* /__vite_hmr { proxy_pass http://frontend:5173; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; } ``` ### Common Vite + Nginx Issues 1. **HMR not working**: Ensure WebSocket headers are set correctly 2. **Too many redirects**: Use `proxy_pass http://host;` without trailing slash to avoid path manipulation 3. **Connection timeout**: Default proxy timeouts may be too short; increase to 30-60 seconds for WebSocket connections --- ## Load Balancing & Upstreams ### Upstream Configuration ```nginx upstream backend { least_conn; # Route to server with fewest connections server backend1:8000 max_fails=3 fail_timeout=30s; server backend2:8000 max_fails=3 fail_timeout=30s; server backend3:8000 backup; # Fallback server keepalive 32; # Connection pooling keepalive_requests 100; keepalive_timeout 60s; } ``` ### Load Balancing Methods - `round_robin` (default): Distribute requests evenly - `least_conn`: Route to server with fewest active connections - `ip_hash`: Consistent routing based on client IP (session persistence) - `hash $request_uri consistent`: Route based on URI ### Health Checks (Nginx Plus) ```nginx upstream backend { zone backend 64k; server backend1:8000; server backend2:8000; health_check interval=5s fails=3 passes=2; } ``` --- ## Rate Limiting ### Zone Definitions ```nginx # In http block limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s; limit_conn_zone $binary_remote_addr zone=conn_limit:10m; ``` ### Application in Locations ```nginx # General API rate limiting location /api/ { limit_req zone=api_limit burst=20 nodelay; limit_conn conn_limit 20; proxy_pass http://backend; } # Stricter for authentication location /api/auth/ { limit_req zone=auth_limit burst=5 nodelay; proxy_pass http://backend; } ``` **Parameters**: - `rate`: Requests per second (or `r/m` for per minute) - `burst`: Allow temporary burst above rate - `nodelay`: Process burst requests immediately - `limit_conn`: Max simultaneous connections --- ## Logging & Monitoring ### Custom Log Format with Timing ```nginx log_format main_timed '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' 'rt=$request_time uct="$upstream_connect_time" ' 'uht="$upstream_header_time" urt="$upstream_response_time"'; ``` ### Buffered Logging for Performance Logging every request directly to disk is expensive; buffering reduces write operations: ```nginx access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s; ``` ### Conditional Logging ```nginx # Don't log health checks location /health { access_log off; return 200 "healthy\n"; } # Log only errors for static files location ~* \.(jpg|png|css|js)$ { access_log off; error_log /var/log/nginx/static_error.log; } ``` --- ## Complete Configuration Examples ### Docker Compose Integration ```yaml # docker-compose.prod.yml version: '3.8' services: nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./conf/nginx/http.conf:/etc/nginx/http.conf:ro - ./conf/nginx/prod.nginx:/etc/nginx/nginx.conf:ro - ./ssl:/etc/nginx/ssl:ro - nginx_cache:/var/cache/nginx depends_on: - backend networks: - app_network backend: build: context: ./backend dockerfile: Dockerfile.prod expose: - "8000" networks: - app_network volumes: nginx_cache: networks: app_network: ``` ### Minimal Dev Configuration ```nginx # Absolute minimum for development http { include mime.types; map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream backend { server backend:8000; } server { listen 80; location /api/ { proxy_pass http://backend; } location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; } location / { proxy_pass http://frontend:5173; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } } } ``` --- ## Key Takeaways ### WebSocket Requirements 1. Map `$http_upgrade` to `$connection_upgrade` 2. Set `proxy_http_version 1.1` 3. Set headers: `Upgrade $http_upgrade` and `Connection $connection_upgrade` 4. Increase timeouts: `proxy_read_timeout 3600s` 5. Disable buffering: `proxy_buffering off` ### Dev vs Prod Differences - **Dev**: Verbose logging, no caching, permissive CORS, proxy to Vite - **Prod**: Error logging only, aggressive caching, strict security headers, serve static files ### File Organization - **http.conf**: Upstreams, maps, shared settings - **dev.nginx**: Full config optimized for development - **prod.nginx**: Full config optimized for production ### Performance Priorities 1. Worker processes = CPU cores (`worker_processes auto`) 2. High worker connections (4096+) 3. Enable `sendfile`, `tcp_nopush`, `tcp_nodelay` 4. Buffer optimization to avoid disk I/O 5. Keepalive connections for upstreams ### Security Essentials 1. HSTS with preload for HTTPS enforcement 2. Comprehensive CSP to prevent XSS 3. X-Frame-Options to prevent clickjacking 4. Rate limiting on API endpoints 5. Hide server version (`server_tokens off`) ### Static File Serving 1. Long cache for assets (1 year with `immutable`) 2. No cache for HTML files 3. Use cache busting with versioned filenames 4. SPA fallback with `try_files $uri /index.html`