NGINX can calculate a request-rate decision without applying it. With limit_req_dry_run on, the client still receives the normal application response, while $limit_req_status records whether the request passed, would have been delayed, or would have been rejected. That makes dry run a change-admission tool, not merely a syntax check.
HTTP 200 is not the decisive signal by itself. In an isolated NGINX 1.26.3 test, the same six-request burst produced these four outcomes:
| Policy state | Client-visible result | $limit_req_status distribution |
Timing observed in this lab |
|---|---|---|---|
| Queued burst, dry run | 6 × HTTP 200 | 1 PASSED, 2 DELAYED_DRY_RUN, 3 REJECTED_DRY_RUN |
All six completed in under 3 ms |
nodelay burst, dry run |
6 × HTTP 200 | 3 PASSED, 3 REJECTED_DRY_RUN |
All six completed in under 1 ms |
| Queued burst, enforced | 3 × HTTP 200, 3 × HTTP 429 | 1 PASSED, 2 DELAYED, 3 REJECTED |
Accepted requests completed near 0, 1 and 2 seconds |
nodelay burst, enforced |
3 × HTTP 200, 3 × HTTP 429 | 3 PASSED, 3 REJECTED |
Accepted requests completed immediately |
One request per second and burst=2 made the policy deliberately visible in the lab. Those values are not a production recommendation. They expose the difference between queueing two excess requests and admitting those two immediately with nodelay.
NGINX’s official limit request module reference describes a leaky-bucket rate limiter. A shared-memory zone stores state for a key, and limit_req applies that zone to a location. These terms determine what the result means:
$binary_remote_addr. Requests with an empty key are not accounted.limit_req_zone.nodelay: requests admitted within the burst are served immediately. It does not increase the sustained configured rate.$limit_req_status reports PASSED, DELAYED, REJECTED, DELAYED_DRY_RUN, or REJECTED_DRY_RUN.Dry run has been available since NGINX 1.17.1. The current official request-limiting admin guide also recommends observing the effect before enforcing it. NGINX’s upstream dry-run test fixture independently exercises the same dry-run status values.
Add the decision variable to a dedicated access log. $request_time measures the complete NGINX request, so it exposes delay applied before the content handler. When upstream work is also material, compare it with the method in this guide to NGINX request and upstream timing; otherwise a one-second queue can be misdiagnosed as a slow application.
Use a disposable listener before adapting the configuration to a staging virtual host. The fixture below does not edit the system NGINX configuration. It owns one random directory, binds only to 127.0.0.1:18086, runs as www-data, records the host config hash, and refuses to start if the port is already occupied.
set -Eeuo pipefail
RATE_LAB=$(mktemp -d /var/tmp/voxfor-nginx-rate.XXXXXX)
RATE_PORT=18086
RATE_USER=www-data
RATE_NGINX=/usr/sbin/nginx
if ss -H -ltn "sport = :$RATE_PORT" | grep -q .; then
echo "TCP port $RATE_PORT is already in use" >&2
exit 1
fi
mkdir -p "$RATE_LAB"/{conf,logs,html,results}
printf '%s\n' 'owner=voxfor-nginx-rate-lab' > "$RATE_LAB/.voxfor-rate-lab"
printf '%s\n' 'rate-lab-ok' > "$RATE_LAB/html/probe"
printf '%s\n' 'health-ok' > "$RATE_LAB/html/health"
chmod 755 "$RATE_LAB" "$RATE_LAB/html"
chmod 644 "$RATE_LAB/html/"*
chown -R "$RATE_USER:$RATE_USER" "$RATE_LAB"
SYSTEM_NGINX_CONF_HASH=absent
if [ -f /etc/nginx/nginx.conf ]; then
SYSTEM_NGINX_CONF_HASH=$(sha256sum /etc/nginx/nginx.conf | awk '{print $1}')
fi
Separate zones keep the queued and immediate paths from consuming each other’s state. Both zones use the same client key, rate and burst. limit_req_status 429 makes an enforced rejection explicit for an API client; NGINX’s default is 503. The exact /health location has no limit_req directive and is the negative control.
cat > "$RATE_LAB/conf/nginx.conf" <<EOF
pid logs/nginx.pid;
error_log logs/error.log notice;
events {
worker_connections 128;
}
http {
log_format rate '\$msec|\$uri|\$status|\$request_time|\$limit_req_status';
access_log logs/access.log rate;
limit_req_zone \$binary_remote_addr zone=queued:1m rate=1r/s;
limit_req_zone \$binary_remote_addr zone=immediate:1m rate=1r/s;
server {
listen 127.0.0.1:$RATE_PORT;
server_name lab.invalid;
limit_req_status 429;
location = /queued {
limit_req zone=queued burst=2;
limit_req_dry_run on;
alias $RATE_LAB/html/probe;
}
location = /immediate {
limit_req zone=immediate burst=2 nodelay;
limit_req_dry_run on;
alias $RATE_LAB/html/probe;
}
location = /health {
alias $RATE_LAB/html/health;
}
}
}
EOF
chown "$RATE_USER:$RATE_USER" "$RATE_LAB/conf/nginx.conf"
Save the dry-run bytes before starting. The wrapper below always supplies the marker-owned prefix, so it cannot signal the host NGINX master by accident.
rate_nginx() {
runuser -u "$RATE_USER" -- "$RATE_NGINX" -p "$RATE_LAB/" -c conf/nginx.conf "$@"
}
rate_nginx -t
cp -- "$RATE_LAB/conf/nginx.conf" "$RATE_LAB/conf/nginx.conf.dry-run"
DRY_CONFIG_HASH=$(sha256sum "$RATE_LAB/conf/nginx.conf.dry-run" | awk '{print $1}')
rate_nginx
curl -fsS "http://127.0.0.1:$RATE_PORT/health"
printf 'dry_config_sha256=%s\n' "$DRY_CONFIG_HASH"
A successful nginx -t proves syntax and file access. It does not prove the policy. The health request proves the listener works and that the control path returns its expected body.
Use exactly one probe for every comparison. The function clears only the lab access log, starts six curl processes, saves every response code and elapsed time, waits for all of them, then counts the decision field for the requested path.
rate_burst() {
local label=$1
local path=$2
local out="$RATE_LAB/results/$label"
rm -rf -- "$out"
mkdir -p "$out"
: > "$RATE_LAB/logs/access.log"
for i in 1 2 3 4 5 6; do
(
curl -sS -o /dev/null -w "$i|%{http_code}|%{time_total}\n" "http://127.0.0.1:$RATE_PORT/$path" > "$out/$i"
) &
done
wait
sort -t'|' -k1,1n "$out"/*
awk -F'|' -v wanted="/$path" '$2 == wanted { count[$5]++ } END { for (key in count) print "decision|" key "|" count[key] }' "$RATE_LAB/logs/access.log" | sort
}
This is a concurrency probe, not a benchmark. Six local curl processes are enough to make the configured one-request-per-second policy visible; they do not model production throughput or network latency.
nodelay Without Client ImpactRun each dry policy after the listener is healthy. A short pause separates the controlled samples. The paths use independent zones, but the pause also makes the receipt easier to interpret.
printf '%s\n' 'phase|dry_run_queued'
rate_burst dry-queued queued
sleep 3
printf '%s\n' 'phase|dry_run_nodelay'
rate_burst dry-nodelay immediate
Every request returned HTTP 200 because dry run did not apply the calculated delay or rejection. The queued policy nevertheless recorded one pass, two would-be delays and three would-be rejections. The nodelay policy recorded three passes and three would-be rejections because its two burst slots were admitted immediately.
That difference answers two separate questions:
nodelay when bounded immediate admission is preferable.Do not infer either answer from a small synthetic burst alone. Collect representative dry-run decisions through real peak periods, background jobs, retries and deploy traffic. A measured access-log workflow such as this NGINX access-log analysis is useful for building a reproducible sample rather than choosing a limit from averages or intuition.
Only one lab state changed: both occurrences of limit_req_dry_run on became off. It retained the key, zones, rate, burst, nodelay choice, response status and content paths. Validate before reload and verify that exactly two directives changed.
sed -i 's/limit_req_dry_run on;/limit_req_dry_run off;/g' "$RATE_LAB/conf/nginx.conf"
chown "$RATE_USER:$RATE_USER" "$RATE_LAB/conf/nginx.conf"
[ "$(grep -c 'limit_req_dry_run off;' "$RATE_LAB/conf/nginx.conf")" -eq 2 ]
rate_nginx -t
rate_nginx -s reload
sleep 3
Now repeat the same six-request input. Keeping the load unchanged is what makes the client-visible comparison useful.
printf '%s\n' 'phase|enforced_queued'
rate_burst enforced-queued queued
sleep 3
printf '%s\n' 'phase|enforced_nodelay'
rate_burst enforced-nodelay immediate
Both enforced policies accepted three requests and rejected three with HTTP 429. Their accepted-request experience differed:
nodelay policy served all three admitted requests immediately.nodelay spends burst capacity sooner; it does not create a sustained three-request-per-second allowance.Queueing is not automatically kinder. If a client deadline is shorter than the queue delay, the client may disconnect before NGINX releases the request. Use NGINX 499 timing diagnosis when client-closed responses rise after enabling a delayed burst.
Keep the health endpoint independent of both zones:
HEALTH_STATUS=$(curl -sS -o "$RATE_LAB/results/health.txt" -w '%{http_code}' "http://127.0.0.1:$RATE_PORT/health")
[[ "$HEALTH_STATUS" == 200 ]]
grep -Fxq 'health-ok' "$RATE_LAB/results/health.txt"
printf 'health_unlimited=%s\n' "$HEALTH_STATUS"
Representative output from the isolated run:
receipt|dry_queued=http200:6,passed:1,delayed_dry_run:2,rejected_dry_run:3
receipt|dry_nodelay=http200:6,passed:3,rejected_dry_run:3
receipt|enforced_queued=http200:3,http429:3,passed:1,delayed:2,rejected:3
receipt|enforced_nodelay=http200:3,http429:3,passed:3,rejected:3
receipt|health_unlimited=200
receipt|rollback=exact_dry_run_config_and_http200:6
receipt|system_nginx_conf=unchanged
receipt|cleanup=marker_owned_prefix_absent
Enforcement is admissible only when representative dry-run ratios fit the endpoint’s traffic budget, the key identifies the intended callers, the queued or immediate burst behavior matches the client contract, rejected requests receive the chosen status, critical exempt paths remain healthy, syntax and reload checks pass, and the previous config can be restored. A successful six-request lab or low would-be rejection count by itself is insufficient.
Lab results establish the mechanics. Production admission depends on traffic identity and service behavior.
Choose the key before the rate. $binary_remote_addr is compact and appropriate for per-address limiting only when NGINX sees the intended client address. Behind a trusted reverse proxy, configure the real-IP chain correctly before relying on it. Behind carrier-grade NAT or a corporate gateway, many legitimate users can share one address. Do not use a raw secret as a zone key, and remember that an empty key bypasses accounting.
Define success and stop conditions in advance. Record the endpoint, key, observation window, expected peak events, acceptable would-delay ratio, acceptable would-reject ratio, latency budget and client retry contract. Observe at least one representative busy cycle; a universal “leave dry run on for 24 hours” rule is weaker than capturing the events that actually drive the endpoint.
Separate queue tolerance from rejection tolerance. A browser page, webhook receiver and login endpoint can have different deadlines and retry semantics. A burst that looks small in counts may still create two seconds of head-of-line waiting. If clients need an explicit rate-limit response, limit_req_status 429 is clearer than the module’s default 503, but NGINX does not invent the application’s retry policy.
Keep monitoring outside the failure you are testing. Health and readiness paths may need a separate key, separate policy or no limiter at all. After cutover, verify status and response content from outside the host with an external HTTP probe rather than trusting only the reload command.
State the protection boundary. NGINX can admit, delay or reject requests that reach it. If unwanted traffic can saturate the network path before it reaches NGINX, request limiting is too late; upstream DDoS protection is the separate control.
A production change should preserve the current file, run nginx -t, reload rather than restart, watch both decision ratios and client-visible errors, and have a timed rollback owner. On a fleet, apply the same acceptance logic per node and account for how the load balancer distributes clients; a local shared-memory zone is not a cluster-wide counter.
No. limit_req_dry_run on calculates what the limiter would do but does not apply the delay or rejection. The content handler’s normal response still reaches the client. A request that returns 200 can therefore carry DELAYED_DRY_RUN or REJECTED_DRY_RUN in $limit_req_status.
$limit_req_status report?For a request evaluated by the module, the documented values are PASSED, DELAYED, REJECTED, DELAYED_DRY_RUN and REJECTED_DRY_RUN. An unrelated location with no applicable limiter can leave the field empty, which is why the log format should retain URI and HTTP status beside it.
nodelay is added to burst?Without nodelay, excessive requests inside the burst are queued and released at the configured rate. With nodelay, those burst requests are admitted immediately. Requests beyond the burst are still rejected, and the sustained rate does not increase.
For an intentional API rate limit, 429 usually communicates the decision more accurately than 503. Set limit_req_status 429 only when clients and monitoring understand that contract. A 429 alone does not tell a client how long to wait, and changing the status does not replace a tested retry policy.
Long enough to include representative peak traffic, scheduled work, retry storms, deployments and other events that influence the endpoint. That may be hours for a predictable internal service or several business cycles for a public application. Exit dry run only after the decision ratios and client-impact model meet written acceptance criteria.
Use a key whose cardinality matches the policy. Restore the client address only from explicitly trusted proxies before using $binary_remote_addr. If many users share one address, per-IP limiting can punish an entire office or carrier gateway. A trusted edge-issued identity can be better, but do not put unvalidated headers or raw secrets into the zone.
No. The named shared-memory zone is shared by NGINX workers on one instance, not by independent servers. A load-balanced fleet therefore enforces per-node state unless its architecture supplies a separate coordinated limiter. Size and test each node’s policy with the actual distribution strategy.
Rollback in the lab restored the exact run-specific dry-run config, validated it, reloaded it and repeated the queued probe. It then compared the host config hash and removed only the marker-owned directory. A production rollback should restore the exact approved previous file through the site’s normal configuration management, not copy this temporary path.
cp -- "$RATE_LAB/conf/nginx.conf.dry-run" "$RATE_LAB/conf/nginx.conf"
chown "$RATE_USER:$RATE_USER" "$RATE_LAB/conf/nginx.conf"
rate_nginx -t
rate_nginx -s reload
sleep 3
rate_burst rollback-dry-queued queued
CURRENT_CONFIG_HASH=$(sha256sum "$RATE_LAB/conf/nginx.conf" | awk '{print $1}')
[[ "$CURRENT_CONFIG_HASH" == "$DRY_CONFIG_HASH" ]]
if [ "$SYSTEM_NGINX_CONF_HASH" != absent ]; then
[[ "$(sha256sum /etc/nginx/nginx.conf | awk '{print $1}')" == "$SYSTEM_NGINX_CONF_HASH" ]]
fi
rate_nginx -s quit
RATE_LAB_PATH=$RATE_LAB
[[ -f "$RATE_LAB/.voxfor-rate-lab" ]]
[[ "$(<"$RATE_LAB/.voxfor-rate-lab")" == 'owner=voxfor-nginx-rate-lab' ]]
[[ "$RATE_LAB" == /var/tmp/voxfor-nginx-rate.* ]]
find "$RATE_LAB" -depth -mindepth 1 -delete
rmdir "$RATE_LAB"
[[ ! -e "$RATE_LAB_PATH" ]]
If the dry-run distribution, queued latency, client deadline, 429 behavior, health check or external probe misses its acceptance threshold, keep or restore dry run and preserve the logs. Do not increase the rate merely to make rejection counts disappear. Revisit the key, endpoint scope, burst behavior and upstream capacity, then repeat the same evidence cycle. The useful outcome is a policy whose client impact was observed before enforcement and whose previous state is still recoverable.