Rate Limit Django Endpoints with Nginx
Protect Django login, API, and expensive endpoints with Nginx limit_req: zones, burst and nodelay, 429 responses, real client IPs, and dry runs.
Problem statement
A few kinds of traffic can overwhelm a Django application long before real users do:
- credential stuffing and password guessing against
/accounts/login/or/admin/login/ - scrapers crawling search and listing pages as fast as possible
- a buggy API client retrying in a tight loop
- bursts against expensive endpoints such as exports or password reset emails
Each request occupies a Gunicorn worker. With a handful of workers, a single aggressive client can make the site unavailable for everyone. Application-level throttling (Django REST Framework throttles, django-ratelimit) helps, but requests still reach Python and consume workers before being rejected.
Nginx's limit_req and limit_conn modules reject excess requests before they reach Django, using very little CPU. This guide configures targeted limits for sensitive and expensive paths, makes sure they apply to real client IPs, and tests them without locking out legitimate users.
Quick answer
- Make sure Nginx sees real client IPs (configure
realipif behind a load balancer or CDN). - Define
limit_req_zoneentries in thehttpblock keyed on$binary_remote_addr, one per policy (login, API, general). - Apply
limit_req zone=... burst=N nodelay;only in thelocationblocks that need it. - Return
429 Too Many Requestswithlimit_req_status 429;. - Start with
limit_req_dry_run on;and read the logs before enforcing. - Add
limit_connfor endpoints that hold connections (large downloads, streaming). - Keep Django-level throttling for per-user or per-account limits that Nginx cannot express.
Step-by-step solution
1. Get the client IP right first
Rate limiting by IP is only correct if $binary_remote_addr is the client's address. Behind a load balancer or CDN, every request would otherwise share the proxy's IP, and one limit would apply to all users together.
# http block, when behind a trusted load balancer
set_real_ip_from 10.0.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Behind Cloudflare, use Cloudflare's IP ranges and real_ip_header CF-Connecting-IP;. Verify in the access log that $remote_addr shows public client addresses.
2. Define rate limit zones
In the http context (for example /etc/nginx/conf.d/rate-limits.conf):
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=general:20m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
$binary_remote_addruses 4 bytes for IPv4 and 16 for IPv6, keeping memory usage low; 10 MB of shared memory stores state for a large number of addresses.rateis the sustained rate:10r/mallows one request every six seconds on average;10r/sallows one every 100 milliseconds.limit_req_status 429replaces the default503, which clients and monitoring would interpret as a server outage.
3. Apply limits per location
server {
listen 443 ssl;
server_name www.example.com;
location = /accounts/login/ {
limit_req zone=login burst=5 nodelay;
proxy_pass http://unix:/run/myapp/gunicorn.sock;
include /etc/nginx/snippets/django-proxy.conf;
}
location = /admin/login/ {
limit_req zone=login burst=5 nodelay;
proxy_pass http://unix:/run/myapp/gunicorn.sock;
include /etc/nginx/snippets/django-proxy.conf;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://unix:/run/myapp/gunicorn.sock;
include /etc/nginx/snippets/django-proxy.conf;
}
location / {
limit_req zone=general burst=60 nodelay;
proxy_pass http://unix:/run/myapp/gunicorn.sock;
include /etc/nginx/snippets/django-proxy.conf;
}
}
/etc/nginx/snippets/django-proxy.conf:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Do not rate limit /static/ and /media/ with the general zone; a single page load requests many assets, and they are served cheaply by Nginx anyway.
4. Understand burst and nodelay
Without burst, any request arriving faster than the rate is rejected immediately, which breaks normal browsing (a page and its AJAX calls arrive together).
burst=Nallows up to N requests above the rate to be queued.- Without
nodelay, queued requests are released at the configured rate, which adds latency for the client. - With
nodelay, requests within the burst are forwarded immediately, and the burst allowance refills at the configured rate. Requests beyond the burst are rejected.
For login endpoints, rate=10r/m burst=5 nodelay lets a user retry a mistyped password a few times immediately, but an attacker making hundreds of attempts per minute is rejected after the burst.
5. Test with dry run
Before enforcing, log what would be rejected:
location = /accounts/login/ {
limit_req zone=login burst=5 nodelay;
limit_req_dry_run on;
proxy_pass http://unix:/run/myapp/gunicorn.sock;
include /etc/nginx/snippets/django-proxy.conf;
}
In dry run mode, Nginx logs "limiting requests, dry run" but forwards requests normally. Watch the error log for a few days of normal traffic:
sudo grep "dry run" /var/log/nginx/error.log | awk '{print $NF}' | sort | uniq -c | sort -rn | head
If legitimate users or offices behind a shared NAT appear, raise the rate or burst before removing limit_req_dry_run.
6. Limit concurrent connections
limit_conn caps simultaneous connections per key, which helps for long-lived requests such as large exports:
location /exports/ {
limit_conn perip_conn 2;
limit_req zone=api burst=5 nodelay;
proxy_pass http://unix:/run/myapp/gunicorn.sock;
include /etc/nginx/snippets/django-proxy.conf;
}
A client can have at most two export requests in progress at once.
7. Exempt trusted clients
Health checks, internal services, and monitoring should not be limited. Use a geo and map combination so their key is empty (Nginx does not count requests with an empty key):
geo $limit_exempt {
default 0;
10.0.0.0/16 1; # internal network
203.0.113.10/32 1; # office egress IP
}
map $limit_exempt $limit_key {
0 $binary_remote_addr;
1 "";
}
limit_req_zone $limit_key zone=general:20m rate=30r/s;
8. Friendly 429 responses
Browsers show a blank page for a bare 429. Serve a small static error page, and for APIs a JSON body:
error_page 429 = @too_many_requests;
location @too_many_requests {
default_type application/json;
add_header Retry-After 60 always;
return 429 '{"detail": "Too many requests. Please try again later."}';
}
For HTML pages, point error_page 429 at a static file under your web root instead.
9. Keep application-level throttling
Nginx limits by IP. Some protections need application context:
- limiting login attempts per username across many IPs (distributed attacks)
- per-account API quotas
- limiting password reset emails per email address
Use Django REST Framework throttles or a package such as django-axes or django-ratelimit for those, with a shared cache backend. The two layers complement each other: Nginx absorbs floods cheaply, Django applies fine-grained policy.
10. Verify
# Expect several 200/302 responses, then 429s
for i in $(seq 1 15); do
curl -s -o /dev/null -w "%{http_code} " https://www.example.com/accounts/login/
done; echo
sudo tail -n 5 /var/log/nginx/error.log | grep "limiting requests"
Verification check:
- requests beyond the burst receive
429, not503 - logs show the real client IP in limiting messages
- normal page loads, including AJAX calls, do not trigger limits
- monitoring and health checks are never limited
- Gunicorn worker utilization stays stable during a simulated flood of login attempts
Explanation
Nginx rate limiting uses the leaky bucket algorithm: each key has a bucket that drains at the configured rate, bursts fill the bucket, and requests that would overflow it are rejected. Because this happens in Nginx's event loop, rejecting abusive traffic costs almost nothing, whereas letting it reach Django would consume a worker per request. Targeted zones for login, API, and general traffic protect the most exposed endpoints without penalizing normal browsing.
Edge cases / notes
Shared IP addresses
Mobile carriers, universities, and corporate networks put many users behind one IP. Overly strict general limits affect all of them. Keep general limits generous, and strict limits only on sensitive endpoints.
Multiple Nginx servers
Each Nginx instance keeps its own counters. With several load-balanced Nginx servers, the effective limit per client multiplies. For strict global limits, enforce them at a shared layer (CDN, WAF, or application with Redis).
IPv6
Attackers can use many addresses from an IPv6 range. For sensitive endpoints, consider keying on a prefix of the IPv6 address using a map that extracts the /64, or rely on CDN or WAF bot protection.
Choosing initial limits from real traffic
Base limits on observed behavior rather than guesses. Count requests per IP per minute for the paths you plan to limit:
sudo awk '$7 ~ /^\/accounts\/login\// {split($4, t, ":"); print $1, t[2]":"t[3]}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20
This prints the busiest IP and minute combinations for the login path. Legitimate users rarely exceed a handful of login requests per minute; addresses with hundreds are automated. Set the limit comfortably above the highest legitimate values, then observe in dry-run mode.
For general traffic, look at the 99th percentile of requests per IP per second during busy periods, including pages that trigger many AJAX calls, and set the general zone rate and burst above that.
Monitoring rate limiting
Rejections should be visible, not silent:
- count
429responses per location in access logs or metrics - alert on sudden spikes, which can mean an attack or a limit that is too strict after a frontend change
- log the zone name by adding
$limit_req_statusto the access log format (available in Nginx 1.17.6 and later), which showsPASSED,DELAYED,REJECTED, or their dry-run variants per request
log_format limited '$remote_addr [$time_local] "$request" $status limit=$limit_req_status';
access_log /var/log/nginx/access.log limited;
Coordinating with Django throttling
When both layers limit the same endpoint, make sure they do not contradict each other in confusing ways:
- keep Nginx limits looser than Django's per-user limits, so authenticated users usually see Django's
429with its clearer message andRetry-Afterheader - use the same status code (
429) at both layers - document both sets of limits in one place for support staff
Common mistakes
- Rate limiting behind a proxy without configuring real client IPs.
- Applying a strict limit to
location /including static files. - Leaving the default
503status for rejected requests. - Enforcing new limits without a dry run period.
Next steps
- Get client IPs right with Fix Wrong Client IP Addresses in Django Behind a Proxy.
- Add per-user throttles with Deploy a Django REST Framework API to Production.
- Protect the admin further with the Django Admin Security Checklist.
- Add edge protection with Deploy Django Behind Cloudflare.
FAQ
How do I rate limit the Django login page with Nginx?
Define a zone such as limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; and apply limit_req zone=login burst=5 nodelay; in an exact location for the login URL.
What does nodelay do in Nginx limit_req?
It forwards requests within the burst allowance immediately instead of spacing them out at the configured rate. Requests beyond the burst are still rejected.
Why does Nginx return 503 when rate limiting?
That is the default status for limit_req. Set limit_req_status 429; so clients and monitoring recognize rate limiting instead of an outage.
Should I rate limit in Nginx or in Django?
Both, for different purposes. Nginx cheaply rejects floods by IP before they use Django workers; Django handles per-user and per-account limits that need application context.