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; # Shower-app rate limit on /admin/login/ (the only admin path the public # proxy exposes). 3r/s with django-axes (5 strikes, 1h lockout) gives # plenty of room for a real staff login while making brute-force costly. limit_req_zone $http_fly_client_ip zone=shower_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; } # Per-service deny list for the shower app — populated by fail2ban # when /admin/login/ attempts trip the threshold. Same scheme as forge. geo $http_fly_client_ip $shower_banned { default 0; include /etc/nginx/shower-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; # WebSocket-aware Connection header. Only send "upgrade" when the client # actually requests a protocol switch; otherwise empty string to preserve # upstream keepalive connections. map $http_upgrade $connection_upgrade { default ""; websocket upgrade; } # --- Upstream --- # DNS resolved via Tailscale MagicDNS at config load. resolver 100.100.100.100 valid=30s; resolver_timeout 5s; # All services route through Caddy on indri. Indri's host-level Tailscale # can establish direct WireGuard peering, avoiding the DERP relay # bottleneck that k8s-hosted Tailscale Ingress pods cannot escape. upstream indri_backend { server indri.tail8d86e.ts.net:443; keepalive 16; } # --- 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 / { proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name docs.ops.eblu.me; proxy_set_header Host docs.ops.eblu.me; proxy_intercept_errors on; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; # 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 / { proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name cv.ops.eblu.me; proxy_set_header Host cv.ops.eblu.me; proxy_intercept_errors on; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; 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 expensive endpoints location = /robots.txt { default_type text/plain; return 200 "User-agent: *\nDisallow: /mirrors/\nDisallow: /user/\nDisallow: /users/\nDisallow: /*/archive/\nDisallow: /*/releases/download/\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"; } # Redirect archive endpoints to tailnet — archive requests generate full # git bundles on demand. Unauthenticated crawlers hitting unique commit # SHAs cause unbounded CPU and disk usage (DoS vector). Legitimate users # can download via forge.ops.eblu.me on the tailnet. location ~ ^/[^/]+/[^/]+/archive/ { default_type text/html; return 302 https://forge.ops.eblu.me$request_uri; } # Rate-limit authentication endpoints location ~ ^/user/(login|sign_up|forgot_password) { limit_req zone=forge_auth burst=5 nodelay; proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name forge.ops.eblu.me; proxy_intercept_errors on; proxy_set_header Host forge.ops.eblu.me; 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 $connection_upgrade; } # Cache release artifact downloads — immutable files keyed by tag+filename. # Avoids hammering Forgejo when crawlers or users re-download the same asset. location ~ ^/[^/]+/[^/]+/releases/download/ { proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name forge.ops.eblu.me; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; proxy_cache services; proxy_cache_valid 200 7d; proxy_cache_key $host$uri; proxy_set_header Host forge.ops.eblu.me; 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; add_header X-Cache-Status $upstream_cache_status; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } # Selectively cache static assets only location ~* \.(css|js|png|jpg|svg|woff2?)$ { proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name forge.ops.eblu.me; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; proxy_set_header Host forge.ops.eblu.me; 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_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 / { proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name forge.ops.eblu.me; proxy_intercept_errors on; # NO proxy_cache — dynamic content with sessions proxy_set_header Host forge.ops.eblu.me; 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 $connection_upgrade; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } } # --- shower.eblu.me (dynamic Django: guest splash + raffle/prize console) --- # Public-facing Adelaide baby shower app. Defense layers: # * geo+fail2ban deny list ($shower_banned) # * nginx limit_req on /admin/login/ via the shower_auth zone # * django-axes inside Django (5 fails / 1h lockout per user+IP) # * /admin/ paths blocked at the proxy except /admin/login/ and /admin/logout/ # so staff can sign in publicly but the CRUD admin is tailnet-only server { listen 8080; server_name shower.eblu.me; # Block fail2ban-banned IPs if ($shower_banned) { return 403 "Temporarily blocked. Try again later.\n"; } # General per-IP rate limit (cushion for the splash page + form posts) limit_req zone=general burst=20 nodelay; # Image uploads from /host/'s prize cropper are ~150-300 KiB JPEGs; # 5 MiB matches the Django-side cap. client_max_body_size 5m; # Security headers — HSTS matches Django's SECURE_HSTS_SECONDS. 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 "same-origin" always; error_page 502 503 504 /error.html; location = /error.html { root /usr/share/nginx/html; internal; } # GNU Terry Pratchett — keep the name moving. add_header X-Clacks-Overhead "GNU Terry Pratchett" always; # Reject indexers — there's nothing here we want crawled. location = /robots.txt { default_type text/plain; return 200 "User-agent: *\nDisallow: /\n"; } # Public admin surface: only the login/logout endpoints, rate-limited. location ~ ^/admin/(login|logout)/? { limit_req zone=shower_auth burst=5 nodelay; proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name shower.ops.eblu.me; proxy_intercept_errors on; proxy_set_header Host shower.ops.eblu.me; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $http_fly_client_ip; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; } # Block the rest of /admin/ at the public edge. The admin CRUD UI # is only reachable via shower.ops.eblu.me on the tailnet. location /admin/ { return 403 "The Django admin is tailnet-only — visit shower.ops.eblu.me.\n"; } location / { proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; proxy_ssl_name shower.ops.eblu.me; proxy_intercept_errors on; # No proxy_cache — dynamic content with sessions and CSRF. proxy_set_header Host shower.ops.eblu.me; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $http_fly_client_ip; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } } # 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; } } }