diff --git a/README.md b/README.md index 4c17c5e..ee97b7c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ http { Use `kit/http/websocket-map.conf` only when a `location {}` will include `kit/proxy_pass/websocket.conf`. +```nginx +http { + include kit/http/log-format-upstream.conf; +} +``` + +Use `kit/http/log-format-upstream.conf` when you want a reusable access log +format with upstream timing fields. It only defines `upstream_timing`; each +server still opts in with its own `access_log` directive. + ### Reverse proxy Plain HTTP reverse proxying only needs the `location {}`-level proxy snippets: @@ -100,6 +110,33 @@ server { } ``` +### Streaming reverse proxy + +For SSE, token streaming, or other incremental responses, add the streaming and +long-timeout snippets to the proxied location: + +```nginx +http { + include kit/http/log-format-upstream.conf; + + server { + include kit/listen/http.conf; + access_log /var/log/nginx/app.access.log upstream_timing; + + location /events/ { + include kit/proxy_pass/forwarded.conf; + include kit/proxy_pass/streaming.conf; + include kit/proxy_pass/timeout-300.conf; + proxy_pass http://app_backend; + } + } +} +``` + +Use `kit/proxy_pass/streaming.conf` only for locations that genuinely need +incremental flushing. It intentionally changes buffering behavior and forces +HTTP/1.1 for that location. + ### Websocket reverse proxy Websocket proxying adds one `http {}`-level dependency plus the websocket location snippet: @@ -150,6 +187,7 @@ server { ## Snippet reference - `kit/http/gzip.conf`: gzip compression for common text-based responses. Must be included inside `http {}`. +- `kit/http/log-format-upstream.conf`: defines the `upstream_timing` access log format with upstream timing fields. Must be included inside `http {}`. - `kit/http/websocket-map.conf`: defines `$connection_upgrade` for websocket proxying. Must be included inside `http {}`. - `kit/listen/http.conf`: IPv4 and IPv6 HTTP listeners for `server {}`. - `kit/listen/https.conf`: IPv4 and IPv6 HTTPS listeners for `server {}` without enabling HTTP/2. @@ -158,6 +196,7 @@ server { - `kit/security.conf`: common low-risk security headers and host normalization. Intended for `server {}`. - `kit/security-legacy.conf`: optional legacy compatibility headers such as `X-Download-Options` and `X-Permitted-Cross-Domain-Policies`. - `kit/fastcgi/hide-powered-by.conf`: hides `X-Powered-By` from FastCGI upstream responses. +- `kit/fastcgi/timeout-300.conf`: longer FastCGI timeouts. Intended for `location {}`. - `kit/ssl/security.conf`: TLS protocol and session resumption settings. Intended for `server {}`. - `kit/ssl/hsts.conf`: HSTS header for HTTPS responses. Intended for `server {}`. - `kit/ssl/hsts-preload.conf`: HSTS variant with `preload`. Use only if the whole domain tree is preload-safe. @@ -165,6 +204,8 @@ server { - `kit/redirect/to-primary-domain.conf`: redirects aliases to the primary `server_name`. Intended for `server {}`. - `kit/proxy_pass/forwarded.conf`: standard reverse proxy headers. Intended for `location {}`. - `kit/proxy_pass/hide-powered-by.conf`: hides `X-Powered-By` from proxied upstream responses. +- `kit/proxy_pass/https-upstream.conf`: enables SNI for HTTPS upstreams. Intended for `location {}`. +- `kit/proxy_pass/streaming.conf`: disables proxy buffering for streaming responses and requests. Intended for `location {}`. - `kit/proxy_pass/websocket.conf`: websocket upgrade headers. Requires `kit/http/websocket-map.conf`. - `kit/proxy_pass/timeout-300.conf`: longer proxy timeouts. Intended for `location {}`. @@ -180,6 +221,8 @@ The script validates: - [examples/example.com.conf](examples/example.com.conf:1) as a server-level snippet. - [examples/reverse-proxy.nginx.conf](examples/reverse-proxy.nginx.conf:1) as a complete nginx config. +- The optional logging, streaming, HTTPS-upstream, and timeout snippets via + synthetic configs assembled in the validation script. ## Notes diff --git a/fastcgi/hide-powered-by.conf b/fastcgi/hide-powered-by.conf index 9b9928a..67deb9c 100644 --- a/fastcgi/hide-powered-by.conf +++ b/fastcgi/hide-powered-by.conf @@ -1 +1,3 @@ +# Mirror the proxy snippet so FastCGI-backed apps can drop framework branding +# without forcing the behavior into unrelated FastCGI locations. fastcgi_hide_header X-Powered-By; diff --git a/fastcgi/timeout-300.conf b/fastcgi/timeout-300.conf new file mode 100644 index 0000000..038131a --- /dev/null +++ b/fastcgi/timeout-300.conf @@ -0,0 +1,8 @@ +# Match the long-running proxy timeout profile for FastCGI backends such as PHP +# workers or app servers behind fcgiwrap. +fastcgi_connect_timeout 300; +fastcgi_send_timeout 300; +fastcgi_read_timeout 300; + +# Keep the downstream client socket aligned with the upstream timeout profile. +send_timeout 300; diff --git a/http/gzip.conf b/http/gzip.conf index fd5b378..fd797df 100644 --- a/http/gzip.conf +++ b/http/gzip.conf @@ -1,6 +1,9 @@ # Enable gzip for common text-based responses. gzip on; gzip_vary on; + +# Keep the default compression level moderate so the CPU cost stays predictable +# on small VPS instances. gzip_comp_level 4; gzip_min_length 256; @@ -27,3 +30,6 @@ gzip_types text/plain text/xml text/vtt; + +# Do not list text/html here. nginx already compresses it implicitly, and +# repeating it suggests callers need to keep the two lists in sync. diff --git a/http/log-format-upstream.conf b/http/log-format-upstream.conf new file mode 100644 index 0000000..5d73602 --- /dev/null +++ b/http/log-format-upstream.conf @@ -0,0 +1,23 @@ +# Define a reusable access log format with upstream timing fields, but do not +# enable logging by default. Individual servers still choose their own log path +# and whether this format is worth the I/O cost. +# +# Example: +# access_log /var/log/nginx/access.log upstream_timing; +log_format upstream_timing escape=json + '{' + '"time":"$time_iso8601",' + '"remote_addr":"$remote_addr",' + '"host":"$host",' + '"request":"$request",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"upstream_addr":"$upstream_addr",' + '"upstream_status":"$upstream_status",' + '"upstream_connect_time":"$upstream_connect_time",' + '"upstream_header_time":"$upstream_header_time",' + '"upstream_response_time":"$upstream_response_time",' + '"http_referer":"$http_referer",' + '"http_user_agent":"$http_user_agent"' + '}'; diff --git a/http/websocket-map.conf b/http/websocket-map.conf index 1d89716..fcb45f7 100644 --- a/http/websocket-map.conf +++ b/http/websocket-map.conf @@ -1,3 +1,5 @@ +# Map Upgrade to a reusable Connection value so websocket locations can opt in +# without hard-coding "upgrade" for every request. map $http_upgrade $connection_upgrade { default upgrade; '' close; diff --git a/listen/http.conf b/listen/http.conf index 76cb18d..24c32dd 100644 --- a/listen/http.conf +++ b/listen/http.conf @@ -1,2 +1,5 @@ +# Minimal dual-stack HTTP listener. Keep default_server and proxy_protocol out +# of the shared baseline so projects do not inherit mutually incompatible +# listener behavior by accident. listen 80; listen [::]:80; diff --git a/listen/http2.conf b/listen/http2.conf index a73afed..49375c3 100644 --- a/listen/http2.conf +++ b/listen/http2.conf @@ -1 +1,3 @@ +# Keep HTTP/2 separate from https.conf so older nginx 1.24.x systems can keep +# using the compatibility snippet instead of failing on "http2 on;". http2 on; diff --git a/listen/https-http2.conf b/listen/https-http2.conf index f6091bb..9711ce9 100644 --- a/listen/https-http2.conf +++ b/listen/https-http2.conf @@ -1,2 +1,4 @@ +# Compatibility listener for nginx 1.24.x and distro packages that still +# expect HTTP/2 on the listen directive instead of a standalone "http2 on;". listen 443 ssl http2; listen [::]:443 ssl http2; diff --git a/listen/https.conf b/listen/https.conf index 018660b..c6cb9be 100644 --- a/listen/https.conf +++ b/listen/https.conf @@ -1,2 +1,4 @@ +# HTTPS listener without HTTP/2. Use this together with http2.conf on nginx +# 1.25.1+ so newer installs avoid the "listen ... http2" deprecation warning. listen 443 ssl; listen [::]:443 ssl; diff --git a/proxy_pass/forwarded.conf b/proxy_pass/forwarded.conf index 9e0b6ce..687339a 100644 --- a/proxy_pass/forwarded.conf +++ b/proxy_pass/forwarded.conf @@ -1,11 +1,24 @@ +# Preserve the original Host header, including a non-default port, because many +# upstream frameworks use it when generating absolute URLs. proxy_set_header Host $http_host; + +# Keep the de-facto standard X-Forwarded-* headers and the older Scheme header +# together. Some upstreams still read Scheme while newer ones prefer +# X-Forwarded-Proto. proxy_set_header Scheme $scheme; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; + +# Preserve WebDAV and object-storage style Destination requests when proxying. proxy_set_header Destination $http_destination; proxy_set_header X-Forwarded-Server $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + +# Leave this legacy hint in place because some older applications and middleware +# still branch on it when they know they are behind nginx. proxy_set_header X-NginX-Proxy true; -proxy_redirect off; \ No newline at end of file +# Avoid rewriting Location headers implicitly. Callers can add explicit +# proxy_redirect rules locally if an upstream really needs them. +proxy_redirect off; diff --git a/proxy_pass/hide-powered-by.conf b/proxy_pass/hide-powered-by.conf index 4099e9b..778a616 100644 --- a/proxy_pass/hide-powered-by.conf +++ b/proxy_pass/hide-powered-by.conf @@ -1 +1,4 @@ +# Keep this separate from forwarded.conf so callers can decide whether hiding +# upstream branding is worth potentially masking framework details during +# debugging. proxy_hide_header X-Powered-By; diff --git a/proxy_pass/https-upstream.conf b/proxy_pass/https-upstream.conf new file mode 100644 index 0000000..1f79cb5 --- /dev/null +++ b/proxy_pass/https-upstream.conf @@ -0,0 +1,6 @@ +# Enable SNI when proxy_pass targets an HTTPS origin by hostname. Without this, +# multi-tenant upstreams can return the wrong certificate or application. +proxy_ssl_server_name on; + +# Do not force proxy_ssl_name or proxy_ssl_verify here. Those depend on whether +# the caller proxies to a hostname, an upstream block, or a private CA. diff --git a/proxy_pass/streaming.conf b/proxy_pass/streaming.conf new file mode 100644 index 0000000..16ca80d --- /dev/null +++ b/proxy_pass/streaming.conf @@ -0,0 +1,16 @@ +# Use HTTP/1.1 only in explicit streaming locations. Keeping this out of the +# default forwarded.conf avoids changing connection semantics for every proxy. +proxy_http_version 1.1; + +# Disable buffering so SSE, token streams, and other incremental responses can +# flush chunks immediately instead of waiting for nginx to coalesce them. +proxy_buffering off; + +# Disable request buffering as well for duplex APIs and streaming uploads. Put +# this behind an opt-in snippet because large upload endpoints may want the +# default buffered behavior instead. +proxy_request_buffering off; + +# gzip can delay flushes by collecting more bytes before compression. Turn it +# off in explicit streaming locations even if gzip is enabled globally. +gzip off; diff --git a/proxy_pass/timeout-300.conf b/proxy_pass/timeout-300.conf index 2497b6e..67d2b40 100644 --- a/proxy_pass/timeout-300.conf +++ b/proxy_pass/timeout-300.conf @@ -1,4 +1,9 @@ +# Keep this as an opt-in long-request profile instead of raising timeouts in +# forwarded.conf for every proxy location. proxy_connect_timeout 300; proxy_send_timeout 300; proxy_read_timeout 300; -send_timeout 300; \ No newline at end of file + +# send_timeout covers the downstream client socket too, so long-lived responses +# do not inherit a shorter default than the upstream leg. +send_timeout 300; diff --git a/proxy_pass/websocket.conf b/proxy_pass/websocket.conf index 20067cd..32da76f 100644 --- a/proxy_pass/websocket.conf +++ b/proxy_pass/websocket.conf @@ -1,3 +1,8 @@ +# nginx defaults to proxying with HTTP/1.0. Websocket upgrade requires 1.1, so +# keep that here instead of in the generic forwarded.conf snippet. proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection $connection_upgrade; \ No newline at end of file + +# Use the mapped value from http/websocket-map.conf so non-upgrade requests can +# still close cleanly instead of always advertising "Connection: upgrade". +proxy_set_header Connection $connection_upgrade; diff --git a/redirect/root-to-www.conf b/redirect/root-to-www.conf index 131ad8f..e90b8c4 100644 --- a/redirect/root-to-www.conf +++ b/redirect/root-to-www.conf @@ -1,9 +1,14 @@ set $root_domain ""; +# Derive the apex domain from a www-prefixed primary server_name. This keeps the +# snippet simple, but it also means callers should not use it on multi-name +# server blocks whose first name is not the canonical www host. if ($server_name ~* ^www\.(?.+)$) { set $root_domain $apex_domain; } +# Use 307 so POST and other non-GET methods keep their method during +# canonicalization instead of being rewritten to GET as with many 301/302 flows. if ($host = $root_domain) { return 307 $scheme://$server_name$request_uri; } diff --git a/redirect/to-primary-domain.conf b/redirect/to-primary-domain.conf index eea894f..a367517 100644 --- a/redirect/to-primary-domain.conf +++ b/redirect/to-primary-domain.conf @@ -1,3 +1,5 @@ +# Keep alias canonicalization method-preserving. This is safer than 301 when a +# non-idempotent request accidentally hits an alias host. if ($host != $server_name) { return 307 $scheme://$server_name$request_uri; } diff --git a/redirect/www-to-root.conf b/redirect/www-to-root.conf index 934c1b3..7ee9180 100644 --- a/redirect/www-to-root.conf +++ b/redirect/www-to-root.conf @@ -1,3 +1,5 @@ +# This is the inverse of root-to-www.conf. It assumes the primary server_name +# is already the apex host and only strips a single leading www. label. if ($host = www.$server_name) { return 307 $scheme://$server_name$request_uri; } diff --git a/scripts/validate-docker.ps1 b/scripts/validate-docker.ps1 index 24db28a..631111b 100644 --- a/scripts/validate-docker.ps1 +++ b/scripts/validate-docker.ps1 @@ -35,6 +35,7 @@ $optionalSnippetConfig = @( "" " location /fastcgi {" " include /etc/nginx/kit/fastcgi/hide-powered-by.conf;" + " include /etc/nginx/kit/fastcgi/timeout-300.conf;" " }" "" " location /proxy {" @@ -46,6 +47,38 @@ $optionalSnippetConfig = @( $optionalSnippetConfigShell = $optionalSnippetConfig -replace "`n", "\\n" +$advancedProxyConfig = @( + "events {}" + "" + "http {" + " include /etc/nginx/mime.types;" + " default_type application/octet-stream;" + "" + " include /etc/nginx/kit/http/log-format-upstream.conf;" + "" + " server {" + " include /etc/nginx/kit/listen/http.conf;" + " server_name streaming.example.com;" + " access_log /var/log/nginx/streaming.access.log upstream_timing;" + "" + " location /events/ {" + " include /etc/nginx/kit/proxy_pass/forwarded.conf;" + " include /etc/nginx/kit/proxy_pass/streaming.conf;" + " include /etc/nginx/kit/proxy_pass/timeout-300.conf;" + " proxy_pass http://127.0.0.1:9000;" + " }" + "" + " location /secure-upstream/ {" + " include /etc/nginx/kit/proxy_pass/forwarded.conf;" + " include /etc/nginx/kit/proxy_pass/https-upstream.conf;" + " proxy_pass https://example.com;" + " }" + " }" + "}" +) -join "\n" + +$advancedProxyConfigShell = $advancedProxyConfig -replace "`n", "\\n" + $modernHttp2Config = @( "events {}" "" @@ -79,6 +112,7 @@ $containerCommand = @( "cp /etc/nginx/kit/examples/reverse-proxy.nginx.conf /tmp/nginx-kit/examples/reverse-proxy.nginx.conf" "printf '%b' '$serverSnippetConfigShell' > /tmp/nginx-kit/server-snippet.nginx.conf" "printf '%b' '$optionalSnippetConfigShell' > /tmp/nginx-kit/optional-snippets.nginx.conf" + "printf '%b' '$advancedProxyConfigShell' > /tmp/nginx-kit/advanced-proxy.nginx.conf" "printf '%b' '$modernHttp2ConfigShell' > /tmp/nginx-kit/modern-http2.nginx.conf" "echo 'Validating examples/example.com.conf'" "nginx -t -c /tmp/nginx-kit/server-snippet.nginx.conf" @@ -86,6 +120,8 @@ $containerCommand = @( "nginx -t -c /tmp/nginx-kit/examples/reverse-proxy.nginx.conf" "echo 'Validating optional security and hide-powered-by snippets'" "nginx -t -c /tmp/nginx-kit/optional-snippets.nginx.conf" + "echo 'Validating optional upstream logging, streaming, and HTTPS-upstream snippets'" + "nginx -t -c /tmp/nginx-kit/advanced-proxy.nginx.conf" "echo 'Validating modern http2 on snippets'" "nginx -t -c /tmp/nginx-kit/modern-http2.nginx.conf" ) -join "; " diff --git a/security-legacy.conf b/security-legacy.conf index 6802367..44a2578 100644 --- a/security-legacy.conf +++ b/security-legacy.conf @@ -1,2 +1,5 @@ +# Keep legacy browser-era headers out of the default security baseline. They are +# still occasionally requested by enterprise scanners, but modern browsers +# rarely depend on them. add_header X-Download-Options noopen always; add_header X-Permitted-Cross-Domain-Policies none always; diff --git a/security.conf b/security.conf index be90535..a5316bf 100644 --- a/security.conf +++ b/security.conf @@ -4,9 +4,13 @@ server_tokens off; add_header Referrer-Policy strict-origin-when-cross-origin always; add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; + +# Explicitly disable the legacy XSS Auditor. Modern browsers removed it, and +# some older implementations created security bugs of their own. add_header X-XSS-Protection "0" always; -# Redirect `example.com.` to `example.com` +# Redirect `example.com.` to `example.com`. Use $host on the target so nginx +# emits the normalized host without the trailing dot. if ($http_host ~ "\.$" ){ rewrite ^(.*) $scheme://$host$1 permanent; } diff --git a/ssl/force.conf b/ssl/force.conf index 293f883..2031415 100644 --- a/ssl/force.conf +++ b/ssl/force.conf @@ -1,3 +1,5 @@ +# Preserve the request method during HTTP->HTTPS upgrades. 301 is more common, +# but 307 avoids surprising POST-to-GET rewrites on login and webhook paths. if ($scheme = http) { return 307 https://$http_host$request_uri; -} \ No newline at end of file +} diff --git a/ssl/hsts-preload.conf b/ssl/hsts-preload.conf index 0d29162..a0f95d2 100644 --- a/ssl/hsts-preload.conf +++ b/ssl/hsts-preload.conf @@ -1,5 +1,8 @@ set $hsts_header_value ""; +# Keep the same HTTP/HTTPS guard as hsts.conf. The only difference is the +# preload token, which should be enabled only after the whole domain tree is +# known to be HTTPS-only. if ($scheme = "https") { set $hsts_header_value "max-age=31536000; includeSubDomains; preload"; } diff --git a/ssl/hsts.conf b/ssl/hsts.conf index ffed12f..3949ebf 100644 --- a/ssl/hsts.conf +++ b/ssl/hsts.conf @@ -1,5 +1,7 @@ set $hsts_header_value ""; +# Only emit HSTS on HTTPS responses. This lets a single server block listen on +# both 80 and 443 without sending a meaningless STS header over plain HTTP. if ($scheme = "https") { set $hsts_header_value "max-age=31536000; includeSubDomains"; } diff --git a/ssl/security.conf b/ssl/security.conf index 1927b5a..aedd295 100644 --- a/ssl/security.conf +++ b/ssl/security.conf @@ -1,6 +1,13 @@ +# TLSv1.2+ is the practical modern baseline. Older protocols create more +# compatibility burden than value in a shared default kit. ssl_protocols TLSv1.2 TLSv1.3; + +# Let nginx/OpenSSL pick the best named group set available on the host instead +# of freezing a list that will age badly across distro upgrades. ssl_ecdh_curve auto; +# Keep a small shared cache because session resumption helps repeat visitors, +# but avoid huge caches that imply cross-host coordination. ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/templates/cert/example.com.conf b/templates/cert/example.com.conf index b27e732..6b0ec95 100644 --- a/templates/cert/example.com.conf +++ b/templates/cert/example.com.conf @@ -1,2 +1,4 @@ +# Copy this file into snippets/cert/.conf and replace the paths +# with the certificate material issued for that exact hostname set. ssl_certificate /etc/ssl/certimate/example.com.crt; -ssl_certificate_key /etc/ssl/certimate/example.com.key; \ No newline at end of file +ssl_certificate_key /etc/ssl/certimate/example.com.key;