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.

Limiting request rateRequestkeyed by IPZonerate and burstOver limit429 responseDjangoonly allowed trafficLimiting at the proxy protects workers from the traffic Django never sees.

Quick answer

  1. Make sure Nginx sees real client IPs (configure realip if behind a load balancer or CDN).
  2. Define limit_req_zone entries in the http block keyed on $binary_remote_addr, one per policy (login, API, general).
  3. Apply limit_req zone=... burst=N nodelay; only in the location blocks that need it.
  4. Return 429 Too Many Requests with limit_req_status 429;.
  5. Start with limit_req_dry_run on; and read the logs before enforcing.
  6. Add limit_conn for endpoints that hold connections (large downloads, streaming).
  7. 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;
zones: who is counted, and how fastlimit_req_zone$binary_remote_addrzone=login:10mrate=10r/m;limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;limit_req_status429;12341Counted per client IP: realip must be right.2Logins: ten a minute per IP.3Shared memory: about 160k addresses.4Too Many Requests instead of the default 503.Zones only define limits; locations apply themdjango-deployment.com
Separate zones let logins be strict while pages stay generous.
  • $binary_remote_addr uses 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.
  • rate is the sustained rate: 10r/m allows one request every six seconds on average; 10r/s allows one every 100 milliseconds.
  • limit_req_status 429 replaces the default 503, 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=5 nodelay, request by requestRequests 1–6 arriveat once1 + 5 burst servedimmediately (nodelay)Request 7429 rejectedSlots refillat the zone rateNext requestserved againWithout nodelay, the burst is queued and slowed insteaddjango-deployment.com
nodelay suits pages; queueing suits APIs that tolerate delay.
  • burst=N allows 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;
the supporting settingsUselimit_req_dry_run on✓log what would be limited, block nothinglimit_conn perip_conn 20cap open connections per IPgeo / map exemptionmonitoring and office IPs skip limitserror_page 429a friendly JSON or HTML responseStart in dry run for a week before enforcingdjango-deployment.com
Dry-run log lines show exactly who a limit would have hit.

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
edge limits and app limits togetherNginx✓per IP, before Django runs✓cheap, stops floods✕no idea who the user isDjango / DRF throttling✓per user, API key, or scope✓business rules, like planscosts a request to rejectSet Nginx looser than the app, so the app’s limits apply first for real usersdjango-deployment.com
Shared office IPs are why Nginx limits must stay generous.

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, not 503
  • 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 429 responses 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_status to the access log format (available in Nginx 1.17.6 and later), which shows PASSED, 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 429 with its clearer message and Retry-After header
  • 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 503 status for rejected requests.
  • Enforcing new limits without a dry run period.

Next steps

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.

Keep going