Originally published on kuryzhev.cloud
Your nginx.conf can block 90% of API abuse for free — so why do teams rush to pay for Kong or Cloudflare Enterprise before they even need it? I've watched this happen at three different companies now: a scraper burst hits /search, credential-stuffing hammers /login, or a "trusted" third-party integrator decides your fair-use policy is more of a suggestion. Someone panics, opens a ticket for "add rate limiting," and two sprints later there's a Redis cluster and a Kong Enterprise line item nobody asked for. Nginx rate limiting was sitting there the whole time, already installed, already free.
When you face this choice
The trigger is almost always the same shape: traffic that looks legitimate at the edge but isn't sustainable at the backend. A scraper hammering your product catalog at 200 req/s from a rotating pool of IPs. A credential-stuffing run against /login that's technically valid HTTP but is clearly not a human. Or the more mundane case — a partner integration you signed off on eighteen months ago that's now sending 5x the agreed volume because their product grew and nobody told you.
At that point you're at a fork. Option one: solve it in nginx.conf, close to the socket, cheap and static. Option two: push the logic to a gateway or edge layer that's dynamic, distributed, and — let's be honest — usually costs money or ops overhead you didn't budget for.
The thing that actually forces the decision isn't philosophy, it's topology. If you're running a single nginx instance in front of your API, native rate limiting just works — it has one process, one shared memory zone, one source of truth. The moment you scale to multiple nginx or LB nodes without shared state, that single-node assumption breaks, and you need to decide whether to bolt on synchronization or move the whole problem to a layer built for distributed counting. That's the real question behind "nginx rate limiting vs gateway," and it's worth answering honestly before you write a single limit_req_zone line.
Option A: Native Nginx rate limiting (limit_req / limit_conn)
Nginx has shipped ngx_http_limit_req_module since version 1.1.8 — there's nothing to install on a modern 1.24/1.25 box, it's compiled in by default. That alone makes it the obvious starting point. The pros are hard to argue with: sub-millisecond overhead, request rejection happens at L7 before anything touches your app servers, and the whole config lives in one file you can read top to bottom.
Performance-wise, zone lookups are O(1) against a shared memory hash table, so even at 10k+ req/s the CPU cost is negligible. The real cost — and this bit me once — is logging. If you don't set limit_req_log_level warn;, every single rejection writes a line to error.log, and under a sustained attack that log file grows fast enough to fill a disk.
Now the cons, and they're real. Rate limiting is per-instance — the memory zone lives in one nginx process, so if you run multiple nginx or LB nodes, each one counts independently and your effective global limit multiplies by node count. Limits are IP-based by default, which quietly breaks behind NAT, corporate proxies, or a CDN that masks the real client IP — everyone ends up hashed to the load balancer's address unless you configure real_ip_header correctly. And there's no built-in per-API-key or per-JWT-claim limiting; you're writing map/geo hacks to fake it, which gets ugly fast once you have more than a couple of tiers.
Option B: Gateway/edge-based rate limiting (Kong, Cloudflare, or OpenResty+Redis)
This is where you go when "per IP" stops being granular enough. A Redis-backed layer — whether that's Kong's rate-limiting plugin with policy=redis, or hand-rolled OpenResty with lua-resty-limit-traffic — gives you true distributed counting across N nodes. Per-API-key, per-JWT, per-tenant limits work out of the box with Kong plugins or Cloudflare rulesets, which is exactly what you need once you're billing customers by usage tier.
Cloudflare specifically has the added benefit of blocking abusive traffic before it reaches your infrastructure at all — that saves bandwidth and compute, not just app-layer noise. But it's not free: Cloudflare's Rate Limiting Rules are billed per rule/request volume starting at the Pro plan; the free tier only gives you basic firewall rules, not granular rate limiting. Kong OSS's local-counter default doesn't help you across nodes either — you need the Redis policy, and that policy has noticeably less config flexibility than what Kong Enterprise offers.
The operational cons stack up too. Every request now takes a Redis round-trip — roughly 1-2ms with lua-resty-limit-traffic — which is fine until Redis has a bad night and now your rate limiter is a single point of failure for your entire API. You're also running and monitoring another service, and if you're going the Kong Enterprise route, licensing cost is a real line item, not a rounding error.
Here's the native config I run as a baseline on most APIs before reaching for anything heavier. It covers per-IP request limiting, connection capping for slow-POST protection, and a whitelist for internal/partner traffic:
# /etc/nginx/conf.d/api-ratelimit.conf
# Option A: native nginx rate limiting — single-node setup
# Define shared memory zone: 10m ≈ 160k tracked client IPs
# rate=10r/s is the sustained limit per IP
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# Separate zone for concurrent connection capping (slow-POST protection)
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Whitelist internal monitoring/partner IPs — bypasses limiting entirely
geo $limit_whitelist {
default 0;
10.0.0.0/8 1; # internal network
203.0.113.5/32 1; # trusted partner
}
map $limit_whitelist $limit_key {
0 $binary_remote_addr;
1 ""; # empty key = not tracked/limited
}
limit_req_zone $limit_key zone=api_limit_wl:1m rate=10r/s;
server {
listen 443 ssl;
server_name api.example.com;
# If sitting behind a CDN/LB, must trust the real client IP header
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location /v1/ {
# burst allows short spikes, nodelay serves them immediately
# instead of queuing (avoids added latency for legit bursts)
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 5;
# return 429 instead of nginx's default 503
limit_req_status 429;
limit_conn_status 429;
# avoid flooding error.log on every rejection
limit_req_log_level warn;
add_header Retry-After 1 always;
proxy_pass http://backend_upstream;
}
}
Two gotchas here that I've seen take down or under-protect production more than once. First: nginx's default rejection status is 503 Service Unavailable, not 429. If you don't explicitly set limit_req_status 429;, client retry logic that expects a proper 429 with Retry-After will misbehave — some clients treat 503 as "server is down" and back off way longer than needed, others retry immediately and make things worse. Second: if you're sitting behind Cloudflare or an ALB and you rate limit on $binary_remote_addr without configuring real_ip_header and set_real_ip_from, every single request appears to come from the load balancer's IP. You'll rate limit your entire user base as if it were one client — I've seen this take a service to its knees within minutes of deploy.
Always run nginx -t before reload. A missing m or k suffix on a zone size fails silently in some nginx versions and defaults to a tiny zone — you won't notice until an attack with high IP cardinality evicts old entries via LRU and lets abuse through under sustained load. Test with a quick loop:
# Quick test: hammer the endpoint and observe 429s kick in after burst
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/v1/ping
done
# Expected output (rate=10r/s, burst=20, nodelay):
# 200
# 200
# ... (first ~20 pass due to burst allowance)
# 429
# 429
# 429
# ...
# Check nginx error log for the rejection reason:
tail -f /var/log/nginx/error.log | grep "limiting requests"
# 2024/06/01 12:03:41 [warn] 1234#0: *5678 limiting requests,
# excess: 10.400 by zone "api_limit", client: 203.0.113.9,
# server: api.example.com, request: "GET /v1/ping HTTP/1.1"
One more thing worth knowing: IP-based limiting alone is trivially bypassed by botnets rotating across thousands of residential proxy IPs. If you're facing that kind of adversary, pair rate limiting with request fingerprinting — User-Agent plus TLS JA3 — or a CAPTCHA challenge. Rate limiting alone won't stop a determined attacker with a large IP pool; it just raises the cost of the attack.
Decision matrix
Here's how I map real situations to a choice, based on topology, granularity needs, budget, and team comfort with running Redis/Lua in production:
| Situation | Recommendation |
|---|---|
| Single nginx box fronting an internal or low-traffic public API | Option A — native nginx |
| Startup MVP, no paying multi-tenant customers yet | Option A now, revisit at scale |
| Need per-API-key or per-tenant quotas for billing | Option B — Kong/OpenResty+Redis |
| Multi-region API with paying tenants and SLA tiers | Option B, with edge (Cloudflare) in front |
| High-volume public API, no budget for edge licensing | Option A + fail2ban, tuned aggressively |
| Team has zero Redis/Lua experience and tight deadline | Option A — don't add ops debt for a launch |
Most real setups I've built end up as a hybrid: native nginx handles coarse IP/connection limits at the edge of the config, and a gateway or app-layer service handles business-logic quotas — the "you get 10,000 API calls a month on the Starter plan" kind of enforcement that genuinely needs a database or Redis behind it. That split is honest about which layer is good at what.
My pick
I'll say it plainly: start with native nginx limit_req/limit_conn for anything under roughly 50 req/s baseline traffic and a single-node or simple LB setup. It's free, it's already running, and it handles 90% of the abuse patterns you'll actually see — scraper bursts, login brute-forcing, slow-POST DoS attempts. I stopped recommending Kong or Cloudflare Enterprise rate limiting as a first move after watching a client burn two weeks and a chunk of their infra budget solving a problem that limit_req_zone and a whitelist rule would've fixed in an afternoon.
My concrete setup on new projects: native nginx rate limiting as described above, paired with fail2ban parsing the error log — a filter watching for "limiting requests" that auto-bans repeat offenders at the firewall (iptables/nftables) level so they don't even reach nginx on their next attempt. That combination is free, low-latency, and shockingly effective. I only reach for Redis-backed OpenResty or a real gateway once there's an actual business requirement for per-API-key quotas across multiple nodes — not before. Nginx rate limiting isn't a stopgap you outgrow immediately; for most APIs, it's the right permanent answer. If you're building out the surrounding infra, our DevOps notes and setups cover a lot of the adjacent pieces — logging, load balancer configs, and the fail2ban side of this exact stack. For the module internals, the official ngx_http_limit_req_module docs and Cloudflare's rate limiting rules docs are worth reading before you commit either direction.
Top comments (0)