Rate limiting is one of the most effective first lines of defense against DDoS attacks, brute force login attempts, and API abuse. Nginx includes two powerful built-in modules — limit_req and limit_conn — that let you throttle incoming traffic without installing third-party software. In this guide, you will configure both modules with production-ready examples, whitelist trusted IPs, and test everything before going live.

Prerequisites

  • Nginx 1.18+ installed on a Linux server (Ubuntu/Debian or RHEL/CentOS)
  • Root or sudo access
  • Basic familiarity with Nginx server blocks and configuration syntax
  • A tool for load testing: ab (Apache Bench), wrk, or curl

Understanding Nginx Rate Limiting

Nginx’s limit_req module implements the leaky bucket algorithm. Imagine a bucket with a small hole at the bottom: water (requests) pours in at varying rates, but drains out at a fixed rate. If the bucket overflows, excess water (requests) is rejected.

Two key concepts drive the configuration:

  • Zone: A shared memory area that tracks request counts per key (usually client IP). Defined once in the http block and referenced in server or location blocks.
  • Rate: The allowed throughput expressed as requests per second (r/s) or per minute (r/m). Nginx internally converts everything to per-second granularity.

The limit_conn module works differently — it caps the number of simultaneous open connections from a single key rather than the request rate.

Configuring Request Rate Limiting

Start by defining zones in the http block of /etc/nginx/nginx.conf:

http {
    # General zone: 10 requests/second per IP, 10 MB shared memory
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

    # Strict zone for login endpoints: 1 request/second per IP
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;

    # Return 429 instead of the default 503
    limit_req_status 429;
}

The $binary_remote_addr variable stores client IPs in a compact binary format — 10 MB holds approximately 160,000 addresses.

Now apply these zones in your server block:

server {
    listen 80;
    server_name example.com;

    # General rate limit for all requests
    location / {
        limit_req zone=general burst=20 nodelay;
        proxy_pass http://backend;
    }

    # Strict limit on login page
    location /wp-login.php {
        limit_req zone=login burst=3 nodelay;
        proxy_pass http://backend;
    }

    # Custom 429 error page
    error_page 429 /rate_limit.html;
    location = /rate_limit.html {
        internal;
        return 429 '{"error": "Too many requests. Please retry after a few seconds."}';
        default_type application/json;
    }
}

The burst parameter defines how many excess requests can queue before Nginx starts rejecting. With nodelay, burst requests are processed immediately rather than being spaced out — this provides a better user experience for legitimate traffic spikes.

Configuring Connection Limits

Connection limiting prevents a single IP from holding too many sockets open simultaneously, which is the core mechanism behind slowloris-style attacks:

http {
    limit_conn_zone $binary_remote_addr zone=addr:10m;
    limit_conn_status 429;
}

server {
    # Max 10 simultaneous connections per IP
    limit_conn addr 10;

    # Limit bandwidth per connection (optional)
    limit_rate 100k;

    location /downloads/ {
        limit_conn addr 2;
        limit_rate 50k;
    }
}

Rate Limiting Comparison

Featurelimit_reqlimit_connExternal WAF (Cloudflare/ModSecurity)
ControlsRequest rate (req/s)Concurrent connectionsBoth + Layer 7 patterns
AlgorithmLeaky bucketConnection counterVaries (ML, signatures)
GranularityPer key (IP, header, etc.)Per keyPer rule, geo, ASN
Performance impactMinimalMinimalSlight latency added
DDoS effectivenessGood for L7 floodsGood for slowlorisBest for volumetric
ConfigurationNginx config onlyNginx config onlySeparate service/agent
CostFreeFreeFree tier or paid

For most servers, combining limit_req + limit_conn locally with an external WAF provides defense in depth.

Real-World Scenario

You have a production server running WordPress behind Nginx. Your access logs show thousands of POST requests hitting /wp-login.php and /xmlrpc.php from rotating IPs — a distributed brute force attack. CPU usage is spiking and legitimate users are seeing timeouts.

Here is a production configuration that addresses this:

http {
    limit_req_zone $binary_remote_addr zone=wp_login:10m rate=1r/s;
    limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=1r/m;
    limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
    limit_req_status 429;
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
}

server {
    listen 443 ssl;
    server_name example.com;

    limit_conn conn_limit 15;

    location / {
        limit_req zone=general burst=50 nodelay;
        proxy_pass http://127.0.0.1:8080;
    }

    location = /wp-login.php {
        limit_req zone=wp_login burst=3 nodelay;
        proxy_pass http://127.0.0.1:8080;
    }

    # Block xmlrpc.php almost entirely — 1 request per minute
    location = /xmlrpc.php {
        limit_req zone=xmlrpc burst=1 nodelay;
        proxy_pass http://127.0.0.1:8080;
    }
}

After applying this configuration and running nginx -t && systemctl reload nginx, the brute force traffic gets 429 responses while legitimate users continue authenticating normally.

Whitelisting Trusted IPs

Monitoring systems, health checks, and office networks should bypass rate limits. Use the geo module to create a whitelist:

http {
    geo $rate_limit_key {
        default         $binary_remote_addr;
        10.0.0.0/8      "";    # Internal network
        192.168.1.0/24   "";   # Office network
        203.0.113.50     "";   # Monitoring server
    }

    # Empty key = no tracking = no rate limit
    limit_req_zone $rate_limit_key zone=general:10m rate=10r/s;
}

When $rate_limit_key resolves to an empty string, Nginx skips rate limit tracking entirely for that request.

Testing Rate Limits

Always test in staging before deploying to production. Use limit_req_dry_run on to log violations without blocking:

location / {
    limit_req zone=general burst=20 nodelay;
    limit_req_dry_run on;  # Log only, don't block
}

Then generate traffic with Apache Bench:

# Send 100 requests with 10 concurrent connections
ab -n 100 -c 10 https://staging.example.com/

# Check how many were rate limited
grep "limiting requests" /var/log/nginx/error.log | wc -l

# Check 429 responses in access log
awk '$9 == 429' /var/log/nginx/access.log | wc -l

Or use wrk for sustained load testing:

wrk -t4 -c50 -d30s https://staging.example.com/

Once you are satisfied with the thresholds, remove the limit_req_dry_run directive and reload Nginx.

Gotchas and Edge Cases

  • Clients behind CDN/proxy: If Nginx sits behind Cloudflare, a load balancer, or a reverse proxy, $binary_remote_addr will be the proxy IP — not the client. Use set_real_ip_from and real_ip_header to extract the actual client IP:
set_real_ip_from 173.245.48.0/20;  # Cloudflare IP range
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;
  • Shared IPs (NAT/corporate): Aggressive rate limits can block entire offices behind a single NAT IP. Set reasonable burst values and whitelist known corporate ranges.
  • Zone memory exhaustion: If the zone runs out of memory, Nginx returns 503 for all new keys. Monitor zone usage and size accordingly — 1 MB holds roughly 16,000 IP addresses.
  • Rate vs burst misunderstanding: A rate of 10r/s with burst=20 does not allow 30 requests/second. It allows a burst queue of 20 requests that drain at 10/s. Without nodelay, queued requests experience artificial delay.
  • Log noise: High-traffic rate limiting fills error logs quickly. Use a separate error_log for rate-limited locations or adjust log levels.

Summary

  • Use limit_req to throttle request rates with the leaky bucket algorithm — ideal for login pages, APIs, and general flood protection.
  • Use limit_conn to cap concurrent connections per IP — effective against slowloris and resource exhaustion attacks.
  • Always change the default status code to 429 with limit_req_status 429.
  • Whitelist internal IPs using the geo module so monitoring and health checks are never blocked.
  • Test with limit_req_dry_run on and tools like ab or wrk before enforcing in production.
  • Account for proxies and CDNs by configuring real_ip_header to identify the actual client IP.
  • Combine Nginx rate limiting with Fail2ban and an external WAF for defense in depth.