Reverse proxy, workload, runtime image, and generator research
Research checked on 2026-09-20 and updated on 2026-10-01 for the Astro builder and the TypeScript renderer (schema v2). Sources are upstream NGINX, Docker, and GitHub documentation. This note separates documented behavior from choices that still need a workload test; there is no universally fastest buffer, timeout, cache size, or connection count.
Current baseline
- The current free stable release is NGINX 1.30.5; mainline is 1.31.6. The stable release fixes CVE-2026-90439, while older 1.30 releases do not. Sources: NGINX downloads and NGINX security advisories.
- Stable 1.30 inherited a material 1.29.7 change: proxying now defaults to
HTTP/1.1 and upstream keepalive is enabled by default. The default keepalive
cache is 32 idle connections per worker. It is not a cap on total open
upstream connections. Sources: release news,
proxy_http_version, andkeepalive. - Therefore, old snippets that present
proxy_http_version 1.1,proxy_set_header Connection "", andkeepalive 32as performance tuning are stale for 1.30. They may remain explicit for readability or compatibility, but should not be described as an optimization on the current stable line. - This repository uses the Docker Official Image tag
nginx:1.30.5-alpine, pinned to its reviewed multi-platform index digestsha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94(read withdocker buildx imagetools inspect nginx:1.30.5-alpineon 2026-10-01; the earlier pin was an older rebuild of the same tag). The Docker Hub tag API shows the same digest. That build runs OpenSSL 3.5.8 and is built withhttp_v3,gzip_static,realip,stub_status, and threads; it also shipsngx_http_acme_module.so(docker-nginx Dockerfile, pkg-oss build flags). It has no Brotli or zstd module. The Docker Official Image already ships UID 101; the Dockerfile usesUSER 101:101, port 8080, and/tmppaths for the user site/service runtime. The image contains a generated default config and small welcome site, with complete config and content replaced by read-only mounts at runtime. It contains no Node ordist/assets. This is a deliberate image choice, not the separate nginxinc unprivileged image, so their runtime paths and entrypoint rules must not be mixed.
Reverse proxy rules
Connections and headers
- Let NGINX 1.30 use its HTTP/1.1 and keepalive defaults. Expose keepalive as an advanced option only. A larger idle cache consumes backend capacity and must be sized from worker count, backend limits, and observed reuse.
- Pass
Host $host;$hosthas a safe fallback when the request has no Host, unlike$http_host. PassX-Forwarded-Proto $schemeand, when useful,X-Forwarded-Host $host. Source:proxy_set_header. - At an internet-facing edge, overwrite identity headers:
X-Real-IP $remote_addrandX-Forwarded-For $remote_addr. Do not append an attacker-provided chain.$proxy_add_x_forwarded_forexplicitly appends to the client-supplied value. - When NGINX is behind a load balancer, trust only its exact address/CIDR with
set_real_ip_from, then select the known header withreal_ip_headerand usereal_ip_recursive onfor a trusted proxy chain. Never use0.0.0.0/0or::/0. After normalization, pass the resulting$remote_addrdownstream. Source:ngx_http_realip_module. - For an HTTPS upstream, enable SNI and certificate verification explicitly:
proxy_ssl_server_name on,proxy_ssl_verify on, and a trusted CA file. Verification is off by default. Source: upstream TLS directives.
Buffering, streams, timeouts, and retries
- Keep response buffering on for normal HTTP. NGINX reads the backend quickly
and can shield it from slow clients. Do not guess larger buffers without
measuring response headers, memory, and temporary-file writes. Source:
proxy_buffering. - Disable response buffering only on streaming locations such as SSE. Keep
caching off there. The app should send periodic heartbeat data before
proxy_read_timeoutexpires; that timeout measures the gap between reads, not total response time. - WebSocket locations must pass
Upgradeand a mappedConnectionvalue. NGINX closes an idle tunnel after the read timeout unless the backend sends ping frames or the timeout is raised. Use the upstream WebSocket pattern. proxy_connect_timeout,proxy_send_timeout, andproxy_read_timeoutare workload limits, not speed switches. The send/read limits are gaps between operations, not whole-request deadlines. Keep them visible and documented; raise the read timeout only for known long-polling or streaming endpoints.- The default retry cases are
error timeout. If a multi-server preset adds status retries, bound them withproxy_next_upstream_triesandproxy_next_upstream_timeout. Never addnon_idempotent: NGINX normally avoids retrying POST/PATCH after sending the request, which prevents duplicate side effects. Source:proxy_next_upstream. - Request buffering is on by default. That isolates the backend from a slow
upload and preserves retry options. Offer
proxy_request_buffering offonly for deliberate streaming uploads; after NGINX starts forwarding a body it cannot retry that request. Setclient_max_body_sizeto the application’s actual limit rather than0; the default is 1 MiB. Sources:proxy_request_bufferingandclient_max_body_size.
Proxy cache is opt-in
proxy_cacheis off by default. Generate cache configuration only for a location the user marks public and safe to share. Never enable it globally for an authenticated application.- Keep only GET/HEAD cacheable. Include
$scheme,$host, and$request_uriin the key when one cache zone can serve multiple hosts. Bypass and refuse to store requests withAuthorizationor the application’s session cookie. - Keep NGINX’s handling of
Cache-Control,Set-Cookie, andVary; do not addproxy_ignore_headersfor them. A response withSet-Cookieis not cached by default, andVaryis represented in the cached variant. Source: proxy cache response rules. proxy_cache_lock oncan stop many simultaneous misses from hitting the backend.proxy_cache_revalidate on, background update, and stale-on-error can help a truly public cache, but stale content is a product decision. The cache path needsmax_sizeandinactivebounds and should share a filesystem with its temporary path to avoid copies. Sources:proxy_cache_lockandproxy_cache_path.
Workload presets
Static files and SPA
- Use
try_files $uri =404for a static site. For an SPA usetry_files $uri $uri/ /index.html, but keep/assets/separate withtry_files $uri =404so a missing JavaScript file does not return HTML. Source:try_files. sendfile onis a sensible Linux static-file default.open_file_cachecan reduce repeated file metadata work on a hot, stable tree, but it is off by default and negative caching can hide newly deployed files until revalidation. Make it an explained option. Sources:sendfileandopen_file_cache.- Give content-hashed assets a long
public, max-age=31536000, immutablepolicy. Keepindex.htmlatno-cacheso deployments are discovered. Precompressed.gzassets can be served withgzip_static. The module is not built by default in a source build, but the pinned official image includes it (verified withnginx -V), so the generator turns it on for static and SPA sites; other packages must be checked first. Source:gzip_static.
Go and other HTTP services
- A Go service uses the normal reverse-proxy profile (the former
goprofile was merged into it); it needs no special NGINX module. Default to a single upstream, safe normalized forwarding headers, regular buffering, and explicit application timeouts. WebSocket and SSE behavior is selected per path (its ownlocation) rather than applied to the whole service.
PHP-FPM
- Route ordinary requests through a front controller only after trying a real
static file. In the PHP location, use
try_files $uri =404beforefastcgi_passso a nonexistent script is never handed to PHP-FPM. PassSCRIPT_FILENAME $document_root$fastcgi_script_namethrough the standard FastCGI parameters. NGINX documents thattry_filesperforms this existence check: core module PHP example. - Do not enable PATH_INFO parsing by default. Add it only for frameworks that require it and test the split expression.
- FastCGI connection reuse requires both an upstream
keepalivecache andfastcgi_keep_conn on; it is not automatically enabled by the proxy HTTP/1.1 change. Source: FastCGI keepalive.
Safe generator architecture
- Build a client-only Astro application with React islands. Store choices in
one typed model (
lib/options.ts) and render config with pure functions (lib/render.ts). The same model drives the form, preview, download, examples, docs text, and tests so snippets cannot drift. - Offer bounded choices: the profiles static, SPA, PHP-FPM, and reverse proxy; the targets server/VM and container; WebSocket and streaming paths; public proxy and FastCGI cache; TLS with own certificates or the ACME module; HTTP/3; and real client IP behind Cloudflare or a custom proxy. Keep request buffering enabled in the streaming option. Upload streaming remains a manual route-specific opt-in because it changes retry behavior. Put risky or workload-dependent switches behind an “advanced” explanation or a warning.
- Never accept raw directives. Validate each field as the NGINX token it
represents: host/IP, port, CIDR, server name, size, or duration. Reject
newlines, semicolons, braces, comments, control characters, and unexpected
whitespace. Fixed paths are safer than free-form paths. React’s HTML escaping
protects the page; token validation separately protects the generated config.
Header values use their own validator (no quotes, backslashes,
$, braces, or control characters). - Generate deterministic output and comments. No timestamp in the config.
Download with an in-browser
Blob; no backend, account, analytics, or secret is needed. The generated text is never executed by the web app. - Keep the client-only Astro build separate from the NGINX runtime. Publish
web/through the operator’s Workers deployment; the runtime image does not package the UI or execute downloaded configuration text.
Runtime image
- This repository deliberately uses the Docker Official stable image pinned by
version and digest. Its shipped UID 101 is selected with
USER 101:101; the generated runtime listens on 8080, puts its PID in/tmp, and uses/tmpfor temporary paths. Keep those paths and permissions with this image rather than mixing instructions from a different unprivileged image. - Add a local
/healthzlocation returning 204 and a DockerHEALTHCHECKthat makes an HTTP request to it. A process-only check such asnginx -tdoes not prove the server accepts requests. Docker defines health checks as a runtime liveness test: DockerfileHEALTHCHECK. - Provide a Compose example with a read-only root filesystem,
tmpfsfor/tmp,cap_drop: [ALL], andsecurity_opt: [no-new-privileges:true]. These settings are native Compose controls: Compose service reference. If public proxy caching is enabled, use a separately bounded writable volume or tmpfs and document that tmpfs counts toward the container memory limit. - Bind only the published port, mount the complete config at
/etc/nginx/nginx.confand user content at/usr/share/nginx/htmlread-only, log to stdout and stderr, and run as the image’s numeric non-root user. Do not install shells or debugging tools solely for the health check.
GHCR publishing
- Publish
ghcr.io/risan/nginx-configfrom GitHub Actions with the repositoryGITHUB_TOKEN; grant the job onlycontents: readandpackages: write. GitHub documents this exact flow: publishing Docker images. - Pin third-party actions to full commit SHAs. GitHub states that tags can move and full SHAs fix the exact reviewed code. Source: GitHub Actions threat protection.
- On pull requests and
mainpushes, build and test without logging in or pushing. Publish only from trusted exact stablevMAJOR.MINOR.PATCHtags or an explicitworkflow_dispatch. Use a traceable commit-SHA tag for every image and semantic-version tags for releases; tags can move, so only a digest is immutable. Movelatestonly for a deliberate stable release. - Add
org.opencontainers.image.sourceand standard revision/version labels. Publishing withGITHUB_TOKENlinks the package to the repository. New GHCR packages default to private; the owner must explicitly make the package public. Consumers that require repeatability should pull by digest. Source: GitHub Container registry. - Pin the Docker base image by digest for a reproducible release, then use an update bot or scheduled PR to refresh that digest and rerun the complete image tests. A floating stable tag silently changes the build; a never-updated digest silently misses security rebuilds.
Acceptance checks
- Every generated preset and checked-in example passes
nginx -tinside the pinned free stable 1.30.5 image. - Generator snapshots cover every option alone and the supported combinations; hostile values containing newline, semicolon, brace, comment, or whitespace are rejected before rendering.
- An echo backend proves a forged incoming
X-Forwarded-For,X-Real-IP,Forwarded, andX-Forwarded-Protocannot override the edge identity. When trusted-load-balancer normalization is added manually, a separate test proves only an allowlisted hop changes$remote_addr. - Cache tests prove authorized/session requests bypass the cache,
Set-Cookieresponses are not stored, different Host values cannot share an object, and only GET/HEAD populate the public cache. - An SSE test receives the first event promptly and keeps the connection alive with heartbeat data. A WebSocket test completes a 101 upgrade and echo.
- Upload tests cover just below and above
client_max_body_size. The response-streaming option keepsproxy_request_buffering onwhile disabling response buffering. A separately reviewed upload-streaming location, when used, must prove that the backend receives data before the full body arrives; the normal location buffers it. - Retry tests fail the first backend and succeed on the second for an idempotent request, then prove a sent POST is not duplicated.
- Static/SPA tests prove a real asset is served, a missing asset returns 404,
an application route falls back to
index.html, hashed assets are immutable, andindex.htmlis revalidated. - PHP tests prove only an existing
.phpfile reaches PHP-FPM, in any letter case. Proxy tests prove host, scheme, and normalized client address, upstream connection reuse, and that an untrusted sender cannot set them. HTTPS-upstream tests fail an untrusted certificate. - The built runtime runs as UID 101, starts with a read-only root and only
/tmpwritable, has no Linux capabilities, becomes healthy through HTTP, serves its default or mounted site on 8080 forHost: localhost, and rejects unknown hosts without serving application content. - Pull-request CI builds without registry credentials. A trusted branch/tag
dry run produces the expected OCI labels and tags; the publish job uses only
GITHUB_TOKEN, pushes GHCR successfully when triggered, and emits an attestation tied to the pushed digest.