Skip to content

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

A small ring of calm workers at the centre passing tokens steadily, while a large amber crowd presses at a metered gate around them; only a controlled trickle reaches the workers.

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.

Attack shapes and the nginx directives that answer them
ApplianceAttack shapenginx directiveVerification
Request flood from few sourceslimit_req_zone + limit_req (burst, nodelay)429 rate in access log; limit_req_dry_run first
Many concurrent connections per sourcelimit_conn_zone + limit_connlimit_conn_status 429; $connections in stub_status
Slow-header / slow-body (Slowloris)client_header_timeout, client_body_timeoutreset_timedout_connection; error log at info
Large-header / large-body abuselarge_client_header_buffers, client_max_body_size413 / 400 rate in access log
Descriptor / worker exhaustionworker_connections, worker_rlimit_nofilestub_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.

Where the on-premise layer stops being able to help Your 10 Gbps access circuit Circuit already saturated — upstream only On-premise appliance mitigates 2 Gbps 8 Gbps 25 Gbps 120 Gbps 1 Tbps+ Attack volume (log scale)
Below the circuit, nginx's limits have a share; above it, no directive means anything.

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

  1. Enable stub_status and the extended log; record a baseline week.
  2. Set worker_connections and worker_rlimit_nofile; confirm the OS limit clears them.
  3. Fix real_ip if there is any proxy in front, before writing a single limit.
  4. Add limit_req and limit_conn zones with limit_req_dry_run on; observe for a week.
  5. Turn the dry-run off to enforce; watch the 429 rate.
  6. Set the timeouts and size limits.
  7. 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