Nginx Rate Limiting with limit_req: Protecting a Small VPS Without Blocking Real Users

A single PHP endpoint hit 300 times per second will exhaust a 1 vCPU VPS in under a minute. The usual reflex is to install a firewall rule or a Cloudflare filter, but nginx already ships with a token-bucket rate limiter that runs in the connection-handling hot path with almost no overhead. This tutorial configures limit_req_zone, limit_req, and limit_conn correctly on a 2026-era VPS, then verifies the behaviour with real traffic instead of trusting the config.

What the Token Bucket Actually Does

nginx measures requests per second per key. The key is usually $binary_remote_addr (a 4-byte IPv4 or 16-byte IPv6 representation, far cheaper than a string). Two numbers control behaviour:

  • rate — the sustained refill rate, e.g. 10r/s.
  • burst — how many requests may queue above the rate before nginx returns 503.
  • nodelay — serves the burst immediately instead of spreading it out at the configured rate.

Without nodelay, a browser loading 20 assets at once will be artificially slowed even though the client is legitimate. That is the single most common misconfiguration we see on self-managed boxes.

Step 1: Declare the Zones in the http Block

Zones must live in the http context, because the shared memory segment is allocated once at worker start and then shared by every worker process. On a 2 GB VPS the memory cost is trivial: 10 MB of shared memory holds roughly 160,000 tracked IP states.

# /etc/nginx/nginx.conf
http {
    # general API/HTML requests
    limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;

    # login, search, password-reset: cheap to abuse, expensive to serve
    limit_req_zone $binary_remote_addr zone=strict:10m rate=3r/s;

    # concurrent connections per IP
    limit_conn_zone $binary_remote_addr zone=perip:10m;

    limit_req_status 429;
    limit_conn_status 429;
}

Returning 429 Too Many Requests rather than 503 is deliberate. 503 signals a broken upstream and pollutes your error-rate alerts; 429 tells the client to back off and is understood by search crawlers.

Step 2: Apply the Limits Per Location

server {
    server_name example.com;

    location / {
        limit_req zone=general burst=40 nodelay;
        limit_conn perip 25;
        try_files $uri $uri/ /index.php?$args;
    }

    location = /wp-login.php {
        limit_req zone=strict burst=5 nodelay;
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }

    location ~* /(xmlrpc\.php|wp-cron\.php) {
        limit_req zone=strict burst=2 nodelay;
        deny all;
    }
}

Two details matter here. First, limit_req is inherited by nested locations, so if you set it at server level and then add a stricter one inside a location, both apply — the stricter effective rate wins. Second, static assets served by try_files and a separate location ~* \.(css|js|png)$ block should normally carry no rate limit at all, or a CDN edge will be throttled while fetching them.

Step 3: Whitelist Your Own Monitors

Uptime checks and your CI smoke tests will trip the limiter and generate false alarms. Carve out a map so trusted sources bypass the zones entirely:

geo $limit_key {
    default        $binary_remote_addr;
    203.0.113.7/32 "";
    198.51.100.0/24 "";
}

# then use the map as the zone key
limit_req_zone $limit_key zone=general:10m rate=20r/s;

An empty key is never limited by nginx — the request is simply skipped. Note that the geo module returns the raw value, so only the standard limit_req_zone with a variable key is required.

Step 4: Verify With Real Traffic

Never assume the config works. Reload and hit a limited endpoint 60 times from one IP:

sudo nginx -t && sudo systemctl reload nginx

for i in $(seq 1 60); do
  curl -s -o /dev/null -w '%{http_code} ' https://example.com/api/search
done; echo

A correctly configured 3r/s burst=5 nodelay zone returns a short run of 200 followed by a wall of 429. If you see all 200s, your key variable is empty (likely because a geo or map block failed to load). If you see 503, you left limit_req_status at its default.

Cross-check the limiter against the kernel-level picture while the load test runs, using the same method described in reading load average against real CPU saturation — a limiter that drops requests should visibly reduce CPU pressure.

Choosing Sensible Numbers

Endpoint typerateburstRationale
Static assets (no limit)Browsers fan out 6–8 parallel requests
HTML / GET pages20r/s40 nodelayHandles prefetch and back-button bursts
Search / filtering5r/s10 nodelayIndex scans are the expensive path
Login / password reset3r/s5 nodelayBlocks credential stuffing without lockouts for real users
Concurrent conns per IP25Stops slow-loris style connection hoarding

What Rate Limiting Cannot Fix

If every request is coming from a distinct IP — a botnet or a distributed scraper — per-IP token buckets do nothing. At that point you need a shared key (an API token, a cookie, a session ID) or an edge filter. Similarly, limit_req protects the request rate, not the payload size; a single 500 MB POST upload is governed by client_max_body_size and client_body_timeout instead.

Building a layered defence — nginx limiter, a correctly sized PHP-FPM pool, and a database that is not the bottleneck — is what separates a stable 1 vCPU instance from one that falls over every time a post reaches the front page. If you are choosing the underlying instance for that workload, the sizing trade-offs are laid out in our VPS platform and configuration overview, and the pool arithmetic is covered in tuning PHP-FPM pools by workload type.

Checklist

  • Zones declared in http, sized 10 MB each.
  • nodelay on every burst you do not want artificially slowed.
  • 429 status, not 503, so monitoring stays honest.
  • Stricter zones on login, search, and XML-RPC.
  • Monitors and CI whitelisted via geo.
  • Verified with a 60-request loop, not by eye.

Leave a Reply