From ff1fa319a582b6f5975c789f93426041a948c4aa Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 1 Aug 2026 19:05:24 +0200 Subject: [PATCH] docs: add nginx configuration guide with proxy caching --- README.md | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/README.md b/README.md index edd8ec49..2d0f7f33 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,235 @@ Open `http://localhost:3000` in your browser. --- +## Nginx Configuration + +The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration. + +### Prerequisites + +- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt) +- Next.js running on `127.0.0.1:3000` (default) or your configured port +- Habbo imager (optional) running on `127.0.0.1:3030` + +### Reference Configuration + +Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`: + +```nginx +# ========================================== +# GLOBAL SETTINGS +# ========================================== +server_tokens off; +gzip on; +gzip_vary on; +gzip_proxied off; +gzip_comp_level 6; +gzip_min_length 256; +gzip_types text/plain text/css text/javascript application/json + application/javascript application/xml application/xml+rss + image/svg+xml font/opentype font/ttf font/woff font/woff2; + +# ========================================== +# REDIRECT HTTP → HTTPS +# ========================================== +server { + listen 80; + listen [::]:80; + server_name yourdomain.com www.yourdomain.com; + + location /.well-known/acme-challenge/ { + root /var/www/epicnext/public; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# ========================================== +# MAIN HTTPS SERVER +# ========================================== +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name yourdomain.com www.yourdomain.com; + + root /var/www/epicnext/public; + index index.html; + + # SSL Certificates + ssl_certificate /etc/ssl/cert.pem; + ssl_certificate_key /etc/ssl/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + client_max_body_size 20m; + client_body_timeout 30s; + client_header_timeout 10s; + keepalive_timeout 15s; + send_timeout 10s; + + # Shared Proxy Settings + 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_buffers 16 16k; + proxy_buffer_size 32k; + + # ------------------------------------------ + # Static Files + # ------------------------------------------ + location ^~ /nitro-client/ { + alias /var/www/Nitro-V3/dist/; + expires 7d; + add_header Cache-Control "public"; + access_log off; + } + + location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; } + location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; } + + # ------------------------------------------ + # Next.js Assets (immutable, long cache) + # ------------------------------------------ + location /_next/static/ { + proxy_pass http://127.0.0.1:3000; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location /_next/data/ { + proxy_pass http://127.0.0.1:3000; + add_header Cache-Control "public, max-age=0, must-revalidate"; + } + + # ------------------------------------------ + # API Routes (never cached) + # ------------------------------------------ + location /api/ { + proxy_pass http://127.0.0.1:3000; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # ------------------------------------------ + # Habbo Imager (optional) + # ------------------------------------------ + # Proxies to a Docker container that renders Habbo avatars. + # The imager caches renders to disk, so a long s-maxage is safe. + location /imaging { + proxy_pass http://127.0.0.1:3030; + add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always; + } + + # ------------------------------------------ + # WebSocket (Radio / SSE) + # ------------------------------------------ + location /ws { + proxy_pass http://127.0.0.1:3030; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + + # ------------------------------------------ + # Main Page Proxy (with HTML caching) + # ------------------------------------------ + # The CMS middleware sets: + # Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous) + # Cache-Control: private, no-store (authenticated) + # + # nginx caches anonymous responses and serves them directly, bypassing + # the Node.js process entirely. Authenticated responses are never cached. + # + # proxy_cache_valid: cache 200 responses for 60 seconds + # proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc, + # next-router-*, Accept-Encoding) that would fragment the cache key. + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; + proxy_http_version 1.1; + proxy_buffering on; + + proxy_cache html_cache; + proxy_cache_valid 200 60s; + proxy_cache_key "$host$request_uri"; + proxy_ignore_headers Vary; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_background_update on; + proxy_cache_revalidate on; + add_header X-Cache-Status $upstream_cache_status always; + } + + # ------------------------------------------ + # Health Check + # ------------------------------------------ + location /health { + access_log off; + return 200 "OK"; + add_header Content-Type text/plain; + } + + # Block hidden files + location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) { + deny all; + access_log off; + log_not_found off; + } +} +``` + +### HTML Caching + +The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means: + +- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely. +- **Authenticated visitors** always hit Node.js (personalized content). +- The cache is **auto-invalidated** after 60 seconds and revalidates in the background. + +The proxy cache zone is defined in the `http` block (above any `server` block): + +```nginx +proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off; +``` + +Verify caching works by checking the `X-Cache-Status` response header: + +```bash +# First request (MISS = fetched from Node.js, now cached) +curl -sI https://yourdomain.com/ | grep X-Cache-Status +# → X-Cache-Status: MISS + +# Second request (HIT = served from nginx cache) +curl -sI https://yourdomain.com/ | grep X-Cache-Status +# → X-Cache-Status: HIT +``` + +### Key Points + +| Setting | Value | Why | +| ------- | ----- | --- | +| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer | +| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses | +| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache | +| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance | +| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages | + +--- + ## Production Deployment (PM2) ```bash