Site icon GetPageSpeed

NGINX limit_req Per Hour, Day, Week, Month with NGINX-MOD

NGINX limit_req Per Hour, Day, Week, Month with NGINX-MOD

The 10-requests-per-hour problem

You want NGINX limit_req per hour: throttle a customer-facing API to 10 requests per hour per IP. Or you need to cap a free-tier endpoint at 1000 requests per day. Or you simply want a limit_req_zone that maps cleanly to a “200 per week” abuse rule from your security team.

You write the obvious thing:

limit_req_zone $binary_remote_addr zone=hourly:10m rate=10r/h;

You run nginx -t, and stock NGINX rejects it:

nginx: [emerg] invalid rate "rate=10r/h" in /etc/nginx/conf.d/api.conf:1
nginx: configuration file /etc/nginx/nginx.conf test failed

Stock NGINX only understands two rate units: r/s (requests per second) and r/m (requests per minute). Anything slower than 1r/m forces you into awkward decimal math, third-party limiters, or external rate-limit services. The NGINX-MOD build, however, accepts five additional units out of the box: r/h, r/d, r/w, r/M, and r/Y. This article walks SREs through the upstream root cause, how NGINX-MOD implements every unit cleanly down to 1r/Y, the production recipes you will actually ship, and the zone-sizing reality that determines what your rate limit really does.

Why stock NGINX rejects r/h and friends

The rate string in limit_req_zone is parsed by ngx_http_limit_req_module.c in the upstream NGINX source tree. The relevant block is intentionally small:

if (ngx_strncmp(p, "r/s", 3) == 0) {
    scale = 1;
    len -= 3;

} else if (ngx_strncmp(p, "r/m", 3) == 0) {
    scale = 60;
    len -= 3;
}

rate = ngx_atoi(value[i].data + 5, len - 5);
if (rate <= 0) {
    ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                       "invalid rate \"%V\"", &value[i]);
    return NGX_CONF_ERROR;
}

When you write rate=10r/h, the parser checks the trailing 3 bytes. They match neither r/s nor r/m, so len is not decremented. NGINX then calls ngx_atoi on the string 10r/h, which returns NGX_ERROR because r/h is not a valid integer. Finally, rate <= 0 triggers the invalid rate emergency. Configuration test fails, NGINX refuses to start. Therefore expressing NGINX limit_req per hour with stock builds is impossible without third-party patching.

The internal rate is stored in milli-requests-per-second:

ctx->rate = rate * 1000 / scale;

So 1r/s becomes 1000, 1r/m becomes 16 (with truncation), and the leaky-bucket algorithm leaks rate * elapsed_ms / 1000 units of excess per request. This representation is exactly why the upstream parser only ships the two units that map cleanly. However, that decision has been frustrating SREs since the module was introduced. NGINX-MOD ships its own implementation that fixes both: the unit set and the low-rate truncation.

How NGINX-MOD implements advanced rate limiting

NGINX-MOD’s limit_req parser accepts five additional rate units beyond the upstream r/s and r/m:

Suffix Scale (seconds) Human reading
r/s 1 per second (upstream)
r/m 60 per minute (upstream)
r/h 3,600 per hour
r/d 86,400 per day (24h)
r/w 604,800 per week (7d)
r/M 2,592,000 per 30-day month
r/Y 31,557,600 per Julian year (365.25d)

Two important nuances jump out from the table. First, the month and year units use astronomical averages, not calendar months or leap-aware years. So 1000r/M means “1000 every 2,592,000 seconds”, regardless of whether you cross February or July. Second, r/M is uppercase because lowercase r/m is already taken for minutes. Mistyping 100r/m when you meant 100r/M silently produces a per-minute limit, which is roughly 43,200 times stricter than intended. There is no warning. Therefore the single keystroke difference is the most common footgun with this implementation.

NGINX-MOD also implements the leaky bucket with high-resolution internal storage. Upstream NGINX uses milli-requests-per-second internally and silently truncates anything slower than about 4r/h to zero, so the bucket never leaks at hourly-and-slower rates. NGINX-MOD’s representation has enough headroom to express the full rate string down to 1r/Y (one request per Julian year) without precision loss. Every recipe in this article therefore behaves the way its rate string reads: rate=2r/h burst=1 refills one slot every 30 minutes, rate=1r/d burst=1 refills one slot every 24 hours, and rate=10000r/M burst=200 lets one webhook per 4.3 minutes through on average.

Reproduction: stock vs NGINX-MOD limit_req per hour

You can verify the difference in a clean Rocky Linux 10 VM. Install stock GetPageSpeed NGINX first, then drop in the same config that previously failed:

sudo dnf install nginx
echo 'ok' | sudo tee /usr/share/nginx/html/limit-test.txt

sudo tee /etc/nginx/conf.d/limit-test.conf > /dev/null << 'EOF'
limit_req_zone $binary_remote_addr zone=hourly:10m rate=10r/h;

server {
    listen 8080 default_server;
    root /usr/share/nginx/html;
    location = /limit-test.txt {
        limit_req zone=hourly burst=2 nodelay;
        limit_req_status 429;
    }
}
EOF

sudo nginx -t

Stock NGINX prints the now-familiar error and exits non-zero:

nginx: [emerg] invalid rate "rate=10r/h" in /etc/nginx/conf.d/limit-test.conf:1

Now swap to NGINX-MOD and re-test:

sudo dnf swap nginx nginx-mod
sudo nginx -t
sudo systemctl restart nginx

nginx -t reports success. NGINX-MOD parses every new unit. Moreover, to confirm enforcement is real and not just config-time validation, send 10 parallel requests:

seq 1 10 | xargs -P 10 -I{} \
  curl -s -o /dev/null -w "%{http_code}\n" \
  http://localhost:8080/limit-test.txt | sort | uniq -c

You will see exactly 3 successes and 7 rejections:

      3 200
      7 429

The burst=2 slot accepts the steady-rate request plus two queued bursts, and nodelay serves them immediately. Everything else lands in the 429 bucket. Each rejection is also logged in the NGINX error log:

grep "limiting requests" /var/log/nginx/error.log | tail -3

The same recipe works for r/d, r/w, r/M, and r/Y. Replace the rate, restart, blast the endpoint. The leaky-bucket math is identical, only the refill cadence changes.

Production recipes for NGINX limit_req per hour and beyond

Below are four recipes I have shipped under NGINX-MOD in real customer fleets. Each one is rejected by stock NGINX and accepted by NGINX-MOD.

1. Per-IP API quota: 1000 requests per day

Useful for free-tier APIs that need a hard daily ceiling, but want short bursts to feel responsive:

limit_req_zone $binary_remote_addr zone=api_daily:10m rate=1000r/d;

server {
    listen 443 ssl;
    server_name api.example.com;

    location /v1/ {
        limit_req zone=api_daily burst=20 nodelay;
        limit_req_status 429;
        proxy_pass http://backend;
    }
}

Steady-state rate is one request every 86.4 seconds, with a 20-request burst queue. Anyone hammering the endpoint exhausts the burst quickly and gets clean 429 Too Many Requests until the bucket leaks.

2. NGINX limit_req per hour for password reset

For password-reset endpoints, you want to slow brute force without locking out humans who legitimately mistype:

limit_req_zone $binary_remote_addr zone=pw_reset:10m rate=10r/h;

server {
    listen 443 ssl;
    server_name accounts.example.com;

    location = /password/reset {
        limit_req zone=pw_reset burst=3 nodelay;
        limit_req_status 429;
        proxy_pass http://accounts;
    }
}

Real users get three quick attempts, then have to wait six minutes between subsequent tries. Bots trying to enumerate accounts bottleneck almost immediately. This is the canonical NGINX limit_req per hour pattern: low rate, small burst, dedicated location.

3. Weekly free-tier ceiling per API key

If your API key lives in an Authorization header and you want to enforce a “5000 calls per week” plan tier, hash the key with $http_authorization rather than the raw IP:

limit_req_zone $http_authorization zone=plan_free:20m rate=5000r/w;

server {
    listen 443 ssl;
    server_name api.example.com;

    location /v1/ {
        limit_req zone=plan_free burst=50 nodelay;
        limit_req_status 429;
        proxy_pass http://backend;
    }
}

Each key gets one bucket. The 20m zone holds roughly 320,000 keys at NGINX’s default rb-tree node size, which is plenty for most freemium products.

4. Monthly cap with a generous burst window

For low-volume webhooks, a monthly ceiling with a big burst tolerates legitimate spikes while still capping abuse:

limit_req_zone $binary_remote_addr zone=webhook_month:10m rate=10000r/M;

server {
    listen 443 ssl;
    server_name webhooks.example.com;

    location /incoming/ {
        limit_req zone=webhook_month burst=200 nodelay;
        limit_req_status 429;
        proxy_pass http://webhook_handler;
    }
}

10000r/M is roughly one webhook every 4.3 minutes on average. Therefore the 200-request burst absorbs daily delivery spikes without rejecting legitimate traffic.

Zone sizing matters more than the rate

NGINX limit_req does not store hour or day windows anywhere. There is no per-window counter. What it stores per key is a leaky-bucket excess and a millisecond timestamp inside a single rb-tree node in the shared-memory zone. The zone size you wrote in zone=name:10m directly bounds how many distinct keys can coexist. A 10m zone holds roughly 160,000 IPv4 entries; a 1m zone holds about 16,000.

When the slab allocator runs out of room, ngx_http_limit_req_expire runs in two modes:

/*
 * n == 1 deletes one or two zero rate entries
 * n == 0 deletes oldest entry by force
 *        and one or two zero rate entries
 */

High cardinality silently weakens the limit

This is the case the official GetPageSpeed nginx-mod notes describe. If you set rate=1r/d zone=one:10m and your traffic exceeds 160K unique IPs in 24 hours, returning visitors find their entry has been force-evicted. They get a fresh node with excess=0 and start a new burst allowance, defeating the daily quota. The rule of thumb is to size the zone for at least the number of unique keys you expect within the rate window, not for “current concurrent users”.

Verifying which build you are running

Before you trust a config in production, confirm NGINX-MOD is the active binary:

nginx -v 2>&1
# nginx version: nginx-mod by GetPageSpeed.com/1.30.0

The nginx-mod by GetPageSpeed.com/ prefix is a build-time marker added by the NGINX-MOD spec. If you see plain nginx version: nginx/X.Y.Z, you are on stock and the new units will not parse.

You can also check with rpm -q:

rpm -q nginx-mod
# nginx-mod-1.30.0-45.el10.gps.aarch64

A common gotcha when testing locally

If you spin up a quick test server with return 200 "ok\n";, your limit_req directive will appear to do nothing. Every request returns 200, even at rate=1r/s burst=1 with 100 parallel curls. This is not a NGINX-MOD bug.

The return directive runs in NGINX’s NGX_HTTP_REWRITE_PHASE, which fires before limit_req‘s NGX_HTTP_PREACCESS_PHASE. Therefore a return 200 short-circuits the request before the limiter ever sees it. To exercise the limiter, route through a content-phase handler instead: a static file (root plus a real file on disk), try_files, proxy_pass, or fastcgi_pass. The static-file recipe in the reproduction section above is the fastest way to get a clean test loop.

Installing NGINX-MOD

The full unit set and high-resolution leaky bucket ship with every NGINX-MOD package on every supported distribution.

RHEL, CentOS Stream, AlmaLinux, Rocky Linux

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-mod

Debian and Ubuntu

First, set up the GetPageSpeed APT repository, then install:

sudo apt-get update
sudo apt-get install nginx-mod

NGINX-MOD is a drop-in replacement for the stock nginx binary. Existing configs continue to work unchanged. The new rate units are additive, so any r/s or r/m configuration you already have keeps behaving identically.

When to reach for a different tool

limit_req is single-node by design. Each NGINX worker shares its own copy of the rate-limit zone, but two NGINX servers behind a load balancer track buckets independently. If you need a daily quota that holds across a fleet of edge nodes, the NGINX Redis rate limit module is the right escalation. It uses the same conceptual model but stores buckets in Redis, so all nodes share state.

For application-layer quotas (per-customer billing tiers, per-API-key Pro limits, per-region rules) consider doing the rate-limit decision in your app or at an API gateway. NGINX limit_req is a transport-layer hammer. It excels at blocking abuse at the door, but it does not know your business logic.

Conclusion

NGINX-MOD’s limit_req is one of the smallest distinguishing features it ships, but it is the one customers ask about most when they hit the upstream wall. Hourly, daily, weekly, monthly, and yearly buckets become first-class. Configuration matches the way SREs actually talk about quotas. The trade-offs are also honest: uppercase r/M (one keystroke away from per-minute disaster) and the zone-sizing reality that determines whether your limit is real or theatrical. Zone-sizing is the one design constraint to mind; size the shared-memory zone for at least the number of unique keys you expect within the rate window.

Moreover, if you want the same write-up across other features NGINX-MOD ships against upstream, see NGINX slow_start: gradual upstream ramp-up without Plus and NGINX dynamic TLS records: an honest benchmark in 2026. For the broader story of why NGINX-MOD exists and what else it carries, the NGINX-MOD landing page is the index.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Exit mobile version