Technical deep dive
nginx DDoS Hardening: Connection Limits, Rate Zones and Timeouts by Directive
Last updated: August 2026 · Rate zones, connection limits and timeouts by directive · Reading time ~20 min

nginx resists slow-connection attacks by design — an event loop does not spend a thread per connection — but it does nothing about a request flood until configured. The load-bearing directives are limit_req_zone, limit_conn_zone and the four timeouts, keyed on $binary_remote_addr, sized against your own baseline, and tested with limit_req_dry_run before they drop a single real user.
This guide hardens nginx itself against the request- and connection-exhausting part of a DDoS attack, with no device in front of it. nginx starts from an architectural advantage — its event loop makes it far more resistant to slow-connection attacks than a thread-per-connection server — but that advantage covers exactly one attack shape. Against a request flood, an out-of-the-box nginx does nothing until you give it limits.
Every directive below comes with its value, the counter that shows it working, and where relevant its dry-run. The values are starting points measured against your own traffic; a rate copied from a guide is either useless or is throttling your own users.
| Appliance | Attack shape | nginx directive | Verification |
|---|---|---|---|
| Request flood from few sources | limit_req_zone + limit_req (burst, nodelay) | 429 rate in access log; limit_req_dry_run first | |
| Many concurrent connections per source | limit_conn_zone + limit_conn | limit_conn_status 429; $connections in stub_status | |
| Slow-header / slow-body (Slowloris) | client_header_timeout, client_body_timeout | reset_timedout_connection; error log at info | |
| Large-header / large-body abuse | large_client_header_buffers, client_max_body_size | 413 / 400 rate in access log | |
| Descriptor / worker exhaustion | worker_connections, worker_rlimit_nofile | stub_status active vs worker_connections ceiling |
Every row is set in nginx.conf and verified from the access log or stub_status. None of it helps once the attack fills the circuit ahead of nginx — that is a network-tier problem.
0. Baseline first: turn on the numbers you will tune against
You cannot size a rate limit you have not measured. Enable stub_status and make sure the access
log carries the request time and the limit status:
# In an internal-only server block
location = /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
# A log format that shows rate/conn limiting and timing
log_format ddos '$remote_addr $status $request_time '
'$limit_req_status $limit_conn_status "$request"';
access_log /var/log/nginx/access.log ddos;
# Live connection state
curl -s http://127.0.0.1/nginx_status
# Active connections, reading/writing/waiting; compare 'Active' to worker_connections
# Requests per second per client, from the log, over a normal week
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
The Active number against your worker_connections ceiling, and the per-client request rate from
the log, are the two baselines every threshold below is chosen against.
1. Worker capacity: the ceiling everything else sits under
Before any rate rule, nginx must be allowed enough connections and descriptors, or it exhausts itself before a limit ever fires.
worker_processes auto; # one per core
worker_rlimit_nofile 262144; # must be >= worker_connections * 2
events {
worker_connections 32768; # per worker; total = this * worker_processes
multi_accept on;
use epoll; # Linux; the event loop that makes nginx scale
}
worker_rlimit_nofile must clear worker_connections with room for upstream sockets and files, and
the OS service limit must clear it in turn — a LimitNOFILE in the systemd unit, as covered in the
Linux hardening guide. If the OS limit is lower, that is the real
ceiling no nginx directive can raise.
# Confirm nginx actually got the descriptors
cat /proc/$(pgrep -o -x nginx)/limits | grep 'open files'
2. Rate limiting: limit_req, the primary request-flood defence
limit_req_zone defines a shared-memory zone keyed on the client address; limit_req applies it.
This is the single most important DDoS directive in nginx.
# http context — one zone, keyed on the binary client address
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=2r/s;
server {
location / {
limit_req zone=perip burst=20 nodelay;
limit_req_status 429;
}
# A stricter zone on the expensive path
location = /login {
limit_req zone=login burst=5 nodelay;
limit_req_status 429;
}
}
rate is the sustained ceiling, burst the short-spike allowance, nodelay serves the burst
immediately instead of spacing it out. A 10 MB zone holds roughly 160,000 IPv4 entries because
$binary_remote_addr stores the address in four bytes.
Roll it out with the dry-run first. This is the difference between hardening and an outage:
# Log what WOULD be limited, but let everything through
limit_req_dry_run on;
# Read what the dry-run would have rejected, per client
grep 'limiting requests' /var/log/nginx/error.log | awk '{print $NF}' | sort | uniq -c | sort -rn
Leave limit_req_dry_run on for a representative week, confirm the clients it would limit are not
your real traffic, then turn it off so the rule enforces.
3. Connection limiting: limit_conn against concurrency
Where limit_req caps request rate, limit_conn caps concurrent connections per source —
the defence against a client that opens and holds many connections.
limit_conn_zone $binary_remote_addr zone=connperip:10m;
server {
location / {
limit_conn connperip 20; # max 20 concurrent connections per source
limit_conn_status 429;
}
}
# Rejections show in the access log as the status you set
awk '$2==429' /var/log/nginx/access.log | wc -l
4. Timeouts: turning near-immunity to Slowloris into immunity
nginx does not spend a thread per connection, so a slow connection is cheap — but not free, because the descriptor is finite. The timeouts evict stalled connections before they accumulate:
client_header_timeout 5s; # time allowed to send the full request header
client_body_timeout 5s; # time allowed between body reads
send_timeout 10s; # time allowed between successful writes to the client
keepalive_timeout 30s; # idle keep-alive lifetime
keepalive_requests 100; # requests per keep-alive connection
reset_timedout_connection on; # send RST on timeout, freeing the socket immediately
The short header and body timeouts are the direct Slowloris answer: a connection that dribbles its headers is dropped at 5 seconds, before it ties up a descriptor for minutes.
# Timed-out connections appear in the error log at 'info'
grep -Ei 'timed out|timeout' /var/log/nginx/error.log | tail
5. Buffer and size limits: closing the large-request surface
An attacker can also exhaust memory with oversized headers or bodies. Cap them:
client_max_body_size 10m; # reject bodies larger than this (413)
large_client_header_buffers 4 8k; # count and size of large header buffers
client_header_buffer_size 1k;
# 413 (body too large) and 400 (bad/oversized header) rates
awk '$2==413 || $2==400' /var/log/nginx/access.log | wc -l
6. Getting the client IP right behind a proxy
Every rate and connection zone keys on the client address. Behind a load balancer or CDN, that address is the proxy’s unless you correct it — in which case every real client shares one bucket and the limits are meaningless.
# Trust the proxy ranges and read the real client from its header
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Confirm the log now shows real client IPs, not the proxy's single address
awk '{print $1}' /var/log/nginx/access.log | sort -u | wc -l
If that count is 1, every request is being attributed to the proxy and your limits are keyed wrong. This is the most common reason a carefully written rate limit either throttles everyone or nobody.
7. Serving a cheap response under limit
When a limit fires, serve something cheap rather than an error page that itself costs work:
limit_req_status 429;
limit_conn_status 429;
error_page 429 = @ratelimited;
location @ratelimited {
default_type text/plain;
return 429 "Too Many Requests\n";
}
A static 429 costs almost nothing to serve, so the limit does not become its own small amplification.
The signals to wire into monitoring
# 1. 429 rate — the limits firing
awk '$2==429' /var/log/nginx/access.log | wc -l
# 2. Active vs ceiling — worker saturation
curl -s http://127.0.0.1/nginx_status | awk '/Active/{print $3}'
# 3. Timed-out (Slowloris) connections
grep -c 'timed out' /var/log/nginx/error.log
# 4. Descriptor headroom
cat /proc/$(pgrep -o -x nginx)/limits | grep 'open files'
A rising 429 rate is the limits working; Active approaching worker_connections is the ceiling
being reached and the signal that the request rate has outgrown what the host can absorb.
If the proxy is not nginx
The same controls exist under different names elsewhere, and two are covered separately
because their configuration models differ enough to matter:
HAProxy, where stick tables replace limit_req zones, and
Kubernetes ingress, where the same nginx directives are
reached through annotations and the autoscaler introduces a cost question nginx alone does not
have.
The honest limit of nginx hardening
Everything here defends against request-rate and connection-state exhaustion at the application edge. Two cases sit outside it.
The first is circuit saturation: if volume fills the pipe in front of the server, nginx never receives the packets and no directive applies.
The second is everything below nginx — the kernel’s SYN queues, conntrack table and descriptors, which a request never reaches nginx without passing first. Those are the subject of the Linux hardening guide, and nginx hardening assumes they are already in place. Above the circuit, the answer is the network tier; nginx keeps the application edge standing until then, which is its job and not more than its job.
Order of application
- Enable
stub_statusand the extended log; record a baseline week. - Set
worker_connectionsandworker_rlimit_nofile; confirm the OS limit clears them. - Fix
real_ipif there is any proxy in front, before writing a single limit. - Add
limit_reqandlimit_connzones withlimit_req_dry_run on; observe for a week. - Turn the dry-run off to enforce; watch the 429 rate.
- Set the timeouts and size limits.
- Wire the four signals into monitoring.
Step 3 comes before the limits deliberately: a rate limit keyed on the wrong address is worse than no limit, because it looks like protection while protecting nothing.
Frequently asked questions
- Isn't nginx already immune to Slowloris?
- Largely, and this is worth stating precisely. Slowloris works by tying up a worker thread per slow connection; nginx uses an event loop, so a slow connection costs a file descriptor and a little memory, not a thread, and thousands of them do far less damage than they would to a thread-per-connection server. But "far less" is not "none": descriptors are still finite, so the client_header_timeout and client_body_timeout still matter, and they are what turns near-immunity into actual immunity by evicting the stalled connections.
- Should limit_req use burst and nodelay, or delay?
- For most public endpoints, burst with nodelay. Plain limit_req rejects anything above the rate instantly, which punishes legitimate bursts like a page loading its assets. burst adds a queue that absorbs a short spike; nodelay serves the queued requests immediately rather than spacing them out, which is what a browser expects. delay is for the rarer case where you want to smooth traffic to a backend that cannot handle bursts. Set the rate low and the burst generous, then read the 429 rate before tightening.
- What is $binary_remote_addr and why not $remote_addr?
- Both key the zone on the client address; $binary_remote_addr stores it in 4 bytes for IPv4 instead of a string, so a fixed zone size holds far more entries. A 10 MB zone holds roughly 160,000 IPv4 entries with the binary form. The practical point is capacity: under an attack from many addresses the zone must not fill, or new legitimate clients get evicted alongside attackers, so always key rate and connection zones on the binary form.
- How do I roll out a rate limit without cutting real users?
- With limit_req_dry_run on. It runs the entire limiting logic and writes what it would have rejected to the error log, but lets every request through. You leave it on for a representative week, read which clients would have been limited and at what rate, confirm they are not your real traffic, and only then turn it off so the rule enforces. Shipping a limit_req straight to enforcing, tuned to a number from a guide, is how you rate-limit your own homepage on a busy morning.
- Behind a load balancer or CDN, what breaks?
- The key. If nginx sits behind a proxy, $binary_remote_addr is the proxy's address, so every client shares one rate bucket and the limit is meaningless. You must configure the real client IP first — set_real_ip_from for the proxy ranges and real_ip_header for the header it sends — so the zone keys on the actual client. Getting this wrong is the most common reason a rate limit either does nothing or throttles everyone at once.
- Do these directives stop a volumetric attack?
- No. If the attack fills the circuit in front of the server, nginx never receives the packets and no directive applies. Everything here defends against request-rate and connection-state exhaustion at the application edge. Volume above the circuit is a network-tier problem and is not solved in nginx.conf.
Published: August 2026
This guide is updated as vendors release new models and pricing. How we compare vendors