worker_processes auto; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # JSON access log for Alloy to tail → Loki + metric extraction log_format json_log escape=json '{' '"time":"$time_iso8601",' '"remote_addr":"$remote_addr",' '"client_ip":"$http_fly_client_ip",' '"request_method":"$request_method",' '"request_uri":"$request_uri",' '"status":$status,' '"body_bytes_sent":$body_bytes_sent,' '"request_time":$request_time,' '"upstream_response_time":"$upstream_response_time",' '"upstream_cache_status":"$upstream_cache_status",' '"http_host":"$http_host",' '"http_user_agent":"$http_user_agent"' '}'; access_log /var/log/nginx/access.json.log json_log; # Rate limiting zones — define per-service zones as needed limit_req_zone $http_fly_client_ip zone=general:10m rate=10r/s; # Forge-specific rate limit keyed on real client IP (Fly-Client-IP header). # $binary_remote_addr is Fly's internal proxy IP — all clients share one # bucket. $http_fly_client_ip has the actual client IP. limit_req_zone $http_fly_client_ip zone=forge_auth:10m rate=3r/s; # fail2ban deny list — banned IPs are written here by fail2ban and # checked via the $forge_banned variable. The file is touched at # container start to ensure it exists. geo $http_fly_client_ip $forge_banned { default 0; include /etc/nginx/forge-deny.conf; } # Proxy cache: 200MB, evict after 24h of no access proxy_cache_path /tmp/cache levels=1:2 keys_zone=services:10m max_size=200m inactive=24h; # MagicDNS resolver — using a variable in proxy_pass defers upstream DNS # resolution to request time (not config time). Results are cached for # 30s per worker to avoid per-request DNS lookups. resolver 100.100.100.100 valid=30s; resolver_timeout 5s; # --- docs.eblu.me (static site) --- server { listen 8080; server_name docs.eblu.me; limit_req zone=general burst=20 nodelay; # Serve a friendly error page when upstreams are unreachable # (indri offline, Tailscale tunnel down, emergency shutoff, etc.) # proxy_cache_use_stale still takes priority when cached content exists. error_page 502 503 504 /error.html; location = /error.html { root /usr/share/nginx/html; internal; } location / { set $upstream_docs https://docs.tail8d86e.ts.net; proxy_pass $upstream_docs$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_intercept_errors on; # Cache aggressively — static site only. # Do NOT use these settings for dynamic services. proxy_cache services; proxy_cache_valid 200 1d; proxy_cache_valid 404 1m; proxy_cache_use_stale error timeout updating; proxy_cache_lock on; # Prevent cache-busting: ignore query strings and # client cache-control headers. # Safe for static sites; breaks dynamic services. proxy_cache_key $host$uri; proxy_ignore_headers Cache-Control Set-Cookie; add_header X-Cache-Status $upstream_cache_status; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } } # --- cv.eblu.me (static site) --- server { listen 8080; server_name cv.eblu.me; limit_req zone=general burst=20 nodelay; error_page 502 503 504 /error.html; location = /error.html { root /usr/share/nginx/html; internal; } location / { set $upstream_cv https://cv.tail8d86e.ts.net; proxy_pass $upstream_cv$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_intercept_errors on; proxy_cache services; proxy_cache_valid 200 1d; proxy_cache_valid 404 1m; proxy_cache_use_stale error timeout updating; proxy_cache_lock on; proxy_cache_key $host$uri; proxy_ignore_headers Cache-Control Set-Cookie; add_header X-Cache-Status $upstream_cache_status; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } } # --- forge.eblu.me (dynamic, authenticated) --- server { listen 8080; server_name forge.eblu.me; # Block fail2ban-banned IPs if ($forge_banned) { return 403 "Temporarily blocked. Try again later.\n"; } # General rate limit — higher burst for git operations and CI webhooks limit_req zone=general burst=50 nodelay; # Git LFS and repo uploads can be large client_max_body_size 512m; # Security headers add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; error_page 502 503 504 /error.html; location = /error.html { root /usr/share/nginx/html; internal; } # Serve robots.txt directly — block crawlers from mirror repos location = /robots.txt { default_type text/plain; return 200 "User-agent: *\nDisallow: /mirrors/\n"; } # Block swagger API docs — use forge.ops.eblu.me from tailnet location /swagger { return 403 "API documentation is only available at forge.ops.eblu.me (tailnet).\n"; } # Rate-limit authentication endpoints location ~ ^/user/(login|sign_up|forgot_password) { limit_req zone=forge_auth burst=5 nodelay; set $upstream_forge https://forge.tail8d86e.ts.net; proxy_pass $upstream_forge$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_intercept_errors on; proxy_set_header Host $host; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } # Selectively cache static assets only location ~* \.(css|js|png|jpg|svg|woff2?)$ { set $upstream_forge_static https://forge.tail8d86e.ts.net; proxy_pass $upstream_forge_static$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_cache services; proxy_cache_valid 200 7d; proxy_cache_key $host$uri; add_header X-Cache-Status $upstream_cache_status; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } location / { set $upstream_forge https://forge.tail8d86e.ts.net; proxy_pass $upstream_forge$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_intercept_errors on; # NO proxy_cache — dynamic content with sessions proxy_set_header Host $host; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket support (Forgejo uses it for live updates) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } } # Catch-all: reject unknown hosts, but serve health check server { listen 8080 default_server; location /healthz { return 200 "ok\n"; } location /stub_status { stub_status; allow 127.0.0.1; deny all; } location / { return 444; } } }