nginx-configNGINX 1.30.5

Benchmarking and validation

A configuration is ready when it is accepted by the deployed NGINX build and behaves correctly under the traffic it serves. A higher number from a synthetic benchmark is not enough. Keep the old and new configurations, test the same content and backend, and compare errors, tail latency, CPU, memory, descriptors, network bytes, and disk I/O.

The tuning guide explains why the settings are opt-in. The research notes and proxy notes link to the official directive and release documentation.

Freeze the test

Record these inputs before changing anything:

Do not compare a warm cache with a cold cache, HTTP/1.1 with HTTP/2, or a local backend with a remote backend and call the difference a configuration gain.

Static checks

Run the failure-capable renderer, type, browser, version, and syntax checks first. The example check is read-only; it fails if generated files drift:

set -eu
npm ci
npm run typecheck
node --test tests/*.test.ts
node scripts/generate-examples.mjs --check
node scripts/check-nginx-version.mjs
node scripts/verify-nginx-configs.mjs
npm --prefix web ci
npm --prefix web run test:unit
npm --prefix web run build
npm --prefix web exec -- playwright install --with-deps chromium
npm --prefix web run test:browser
git diff --check

The renderer tests parse the generated text and assert one structural rule per audit item: indentation equals four spaces per brace level, security headers appear once per server, immutable assets are plain prefixes, and so on. The browser app’s checks are described in web/README.md; the build check catches import and production-bundle failures. Chromium and Linux system dependencies are required for the browser tests; the install command above is suitable for a fresh CI runner and may need administrator privileges.

scripts/verify-nginx-configs.mjs runs nginx -t in the pinned stable image over about a hundred generated configs in one container: every profile, target, and HTTPS mode, every option value at least once, and IPv4 and IPv6 QUIC servers with and without a www alias. It fails on any syntax error. Do not replace these checks with a successful file write or a single HTTP request.

The runtime smoke scripts start the real image on a private Docker network. Each takes the image as its argument:

docker build -t nginx-config:local .
sh scripts/smoke-image.sh nginx-config:local     # default site, read-only, non-root, gzip
node scripts/smoke-proxy.mjs nginx-config:local  # identity, keepalive, WebSocket scope, failover, real IP,
                                                 # alias redirect Locations, PROXY-protocol health probe
node scripts/smoke-cache.mjs nginx-config:local  # proxy cache privacy and Vary
node scripts/smoke-php.mjs nginx-config:local    # PHP-FPM, source protection, symlink release, FastCGI cache
node scripts/smoke-static.mjs nginx-config:local # headers once, immutable assets, gzip, gzip_static
node scripts/smoke-tls.mjs nginx-config:local    # redirect to the public port, h2, HSTS, resumption
node scripts/smoke-http3.mjs nginx-config:local  # QUIC listener, a real HTTP/3 request (200 + Alt-Svc), TCP fallback
node scripts/smoke-acme.mjs nginx-config:local   # Pebble: canonical + alias certificates, restart reuses them
node scripts/smoke-resolve.mjs nginx-config:local # backend replaced, new IP, no reload (about 5 s)
node scripts/smoke-bootstrap.mjs nginx-config:local # the printed certbot bootstrap, run from an empty folder (Pebble)
node scripts/smoke-compose.mjs                   # the README Compose commands as printed, HTTP and TLS service
node scripts/smoke-host-install.mjs              # host install/renewal-hook commands keep certbot live symlinks

smoke-http3.mjs makes the HTTP/3 request with a curl that supports it (the ymuski/curl-http3 image pinned by digest, HTTP3_CURL_IMAGE to change it) and always fails without one. scripts/diagnose-http3-listener.mjs is a separate, diagnostic-only command (UDP listener and Alt-Svc, no HTTP/3 request) and does not count as qualification. smoke-proxy.mjs also performs a real WebSocket handshake (101) and frame echo against a small Node backend. smoke-acme.mjs pulls ghcr.io/letsencrypt/pebble and its test DNS server, and needs a free private subnet (random 10.x.x.0/24 by default, SMOKE_ACME_SUBNET to set one). smoke-resolve.mjs replaces the backend container behind a Docker network alias and waits for NGINX to follow the new address by itself. The generated config uses valid=30s; the test shortens it to valid=1s so the switch shows within seconds (about 5 s, mostly one 5 s connect timeout to the old address).

Run the generated and hand-edited files through the same NGINX package that will serve them:

nginx -v
nginx -V 2>&1
sudo nginx -t
sudo nginx -T > /tmp/nginx-expanded.conf

The official free stable image is a useful disposable check when the target configuration uses the same module set:

docker run --rm --user 0 --entrypoint nginx -v "$PWD:/repo:ro" \
  nginx:1.30.5-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 \
  -t -c /repo/docker/nginx.conf

Include, certificate, and log paths must exist in the mounted layout. The root nginx.conf and most of sites-example/ are made for the server target (certificate paths under /etc/letsencrypt, logs in /var/log/nginx), so test those with the files in place on a host, or generate the container target. Use the repository’s CI container check when it prepares those paths or when your target image is different. A syntax test in another image is not proof that the production package can load the file.

Inspect nginx -T for duplicate directives and unexpected inheritance. A reload test should prove that a bad candidate is rejected while known-good workers continue serving traffic:

sudo nginx -t && sudo nginx -s reload
curl -fsS https://example.com/healthz

Request matrix

Run a small deterministic matrix before a load test.

AreaCheck
StaticExisting file, missing file, range request, conditional 304
SPAExisting asset, missing asset returns 404, application route falls back to index.html
Browser cacheHashed asset is immutable; HTML, manifest, and service worker revalidate
GzipSelected public static text has gzip and Vary, also with a Via header; .gz files are served by gzip_static; dynamic profiles stay off unless explicitly reviewed; images/fonts/archives are not recompressed
HeadersEach security header appears exactly once on HTML, a hashed asset, a 404, and /healthz
PHPExisting script runs; made-up .php returns 404 without reaching FPM; .PHP never returns source; a symlink release switch takes effect at once
ProxyHost, scheme, and normalized client address reach the backend; normal requests reuse upstream connections, also on the WebSocket path
Proxy failureConnect/read failure returns the intended error; safe idempotent retry is bounded
WebSocketUpgrade returns 101 and an echo works; idle policy is understood
StreamingFirst event arrives promptly; heartbeat keeps the route alive
UploadAt and above client_max_body_size behave as documented
TLSIntended certificate/SNI and HTTP/2 work; old TLS versions fail
Host routingUnknown Host and SNI never serve application content
SecurityDotfiles, backups, source control, and private keys are not public
Cache privacyAuthorization/session requests bypass; Set-Cookie is not shared

Use an echo backend that returns received headers for identity tests. Send forged X-Forwarded-For, X-Real-IP, Forwarded, and X-Forwarded-Proto values from an untrusted client and verify that the edge overwrites them. Then test the trusted-load-balancer path from an allowlisted address (scripts/smoke-proxy.mjs does this with a second NGINX as the trusted hop). The builder only configures that trust boundary when you list the proxy ranges.

For an HTTPS upstream, test both a valid and invalid certificate. The invalid case must fail closed when verification is enabled.

Load tests

Use a tool that can hold the intended protocol and connection pattern, such as h2load for HTTP/2 or HTTP/3 and wrk/hey for HTTP/1.1. Verify the endpoint status before loading it, and keep status codes, transport errors, and NGINX error-log events separate from throughput:

status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' https://example.com/healthz)
case "$status" in 2*|3*) ;; *) echo "health check returned HTTP $status" >&2; exit 1 ;; esac

Keep the benchmark command and result with the change:

h2load -n 10000 -c 100 -m 10 https://example.com/assets/app.123456.js
wrk -t4 -c100 -d60s https://example.com/

These values are examples, not capacity recommendations. Start below the service’s known capacity, then increase one dimension at a time. Run enough repetitions to see tail behavior. Count non-2xx responses, connection resets, timeouts, and upstream errors explicitly; never treat a client tool error or an HTTP error response as successful throughput. h2load reports protocol/status failures; with wrk or hey, pair the run with access-log status counts and the NGINX error log.

Test more than one workload:

Capture resource evidence while the test runs:

docker stats CONTAINER
ss -s
pidstat -p $(pgrep -o nginx) 1

Avoid embedding the shell expansion in automation without checking the process selection. On a host, use the service’s metrics and a validated worker PID. Also record disk I/O and open descriptors. A faster response with exhausted memory, queued requests, or a growing error log is a regression.

Tuning loop

  1. Establish the baseline and freeze the request matrix.
  2. Change one setting or one related group with a written reason.
  3. Run syntax, security, and correctness checks.
  4. Run the same warm/cold and protocol benchmarks.
  5. Keep the change only when the relevant metric improves without unacceptable errors, tail latency, memory, descriptor, or privacy regressions.
  6. Record the workload, result, and rollback condition next to the change.

Examples of changes that require evidence are larger worker or connection limits, longer keep-alives, bigger buffers, a higher gzip level, open-file caching, AIO/direct I/O, upstream retries, public proxy caching, per-IP rate limits, CPU affinity, reuseport, and HTTP/3.

Results to retain

A useful result records:

date and commit:
nginx -V:
image/package digest:
hardware and limits:
request mix and cache state:
tool and command:
requests, duration, concurrency:
throughput and error count:
p50 / p95 / p99 latency:
CPU / memory / descriptors:
network / disk I/O:
decision and rollback condition:

Keep the raw output and a short interpretation. A benchmark that cannot be repeated on the same inputs is a clue, not proof. For high-risk changes such as cache policy, forwarded identity, TLS, or retries, correctness and isolation take priority over a small throughput improvement.