Bali, Indonesia
Insights

Rate Limiting Blocks Real Visitors Behind Cloudflare? Check Which IP Nginx Sees

November 4, 2026
0:00
You added limit_req to Nginx to slow down bots, and soon after, real visitors start reporting 503 or 429 errors. Traffic graphs look normal, nothing points to an attack, yet the limit keeps tripping. When the site sits behind Cloudflare, the usual reason is that Nginx is not counting visitors at all. It is counting Cloudflare. This walkthrough goes from the error a visitor sees down to the exact line of config that causes it, then shows how to fix it without opening a new hole.
Before touching any config, make sure you are chasing the right component. Three things commonly produce 429 or 503 on a Cloudflare-proxied site, and they look similar from the browser. The first is Cloudflare's own rate limiting or security rules. Those responses are generated at the edge and typically show a Cloudflare-branded error page, so the request never reaches your server. If the visitor's screenshot shows a Cloudflare page, the origin is not involved. The second is the backend. A 503 can also come from PHP-FPM running out of workers or an upstream that is down. In that case the Nginx error log usually mentions the upstream or a connection failure, not rate limiting. The third is limit_req itself. By default, Nginx answers rejected requests with 503, and many admins change that to 429 with limit_req_status 429;. Either way, Nginx writes a line to the error log for every rejection, and that line is the most useful clue in this whole investigation.
BASH
# Recent rate-limit rejections (path depends on your setup)
sudo grep 'limiting requests' /var/log/nginx/error.log | tail -n 20

# Which client addresses are being limited, most frequent first
sudo grep 'limiting requests' /var/log/nginx/error.log \
  | grep -o 'client: [^,]*' | sort | uniq -c | sort -rn | head

# Top source addresses in the access log (first field in the default combined format)
sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
If the first command returns nothing while visitors are getting errors, the limiter is not your problem and you should look at Cloudflare or the backend instead. If it returns plenty of lines, look closely at the client: addresses in the second command.
On an affected server, the client: list is usually short. A small number of addresses account for nearly every rejection, and the access log tells the same story: the top source addresses carry a huge share of the traffic. It looks like a few heavy hitters are hammering the site. The natural reaction is to treat those addresses as attackers and block them. Before doing that, look them up. If they belong to Cloudflare's published IP ranges, they are not visitors. They are the edge servers that forward visitor traffic to your origin. Blocking them cuts off everyone routed through those edges, which is the trap described in Blocking IPs From Your Access Log Won't Stop Bots Coming Through Cloudflare. To see both addresses side by side, add a temporary debug log format. Cloudflare sends the original visitor address in the CF-Connecting-IP request header, which Nginx exposes as $http_cf_connecting_ip.
NGINX
# Inside the http block. Temporary, for diagnosis only.
log_format ipdebug 'remote_addr cf=http_cf_connecting_ip '
                   'xff="http_x_forwarded_for" "request" $status';

# Inside the server block for the affected site
access_log /var/log/nginx/ipdebug.log ipdebug;
Run sudo nginx -t and reload, then watch the new log for a minute. If $remote_addr keeps repeating the same few Cloudflare addresses while cf= shows many different visitor IPs, you have found the problem.
A typical rate limit config looks like this: limit_req_zone binary_remote_addr zone=perip:10m rate=10r/s; in the http block, and limit_req zone=perip burst=20; in a location. The first argument is the key. Nginx keeps one counter per distinct key value, and binary_remote_addr is the address of whatever opened the TCP connection to Nginx. Without Cloudflare in the path, that is the visitor's browser, and the limit works as intended. With Cloudflare proxying, the TCP connection comes from a Cloudflare edge server. Every visitor in a region that happens to be served by the same edge shares that edge's address, so they all land in the same bucket. A rate that is generous for one person (illustrative example: 10 requests per second) is tiny for hundreds of people sharing one counter. One busy page load from each of a few visitors can push the shared bucket past its burst, and the next visitor gets rejected even though they sent a single request. This also explains why the errors seem random. Visitors routed to a busy edge get blocked, visitors on a quiet edge do not, and the pattern shifts as Cloudflare's routing shifts. Customers often describe it as "the site works on my phone but not on office Wi-Fi," which sends support down the wrong path toward DNS or the visitor's ISP. The same issue affects anything else keyed on the client address: limit_conn, allow/deny rules, geo blocks, and the IPs your application logs. Fixing the source address fixes all of them at once.
Nginx has a built-in way to handle this: the ngx_http_realip_module. When a request arrives from an address you declare as trusted, the module replaces remote_addr (and binary_remote_addr) with the value from a header you choose. It runs very early in request processing, before limit_req, so the limiter automatically starts keying on the real visitor. First confirm the module is compiled in. Distribution packages usually include it, but builds from source only have it if configured with it. Run nginx -V 2>&1 | grep -o with-http_realip_module and check that it prints the module name. Then add the trusted ranges and the header. Get the current IPv4 and IPv6 lists from Cloudflare's published IP ranges page rather than copying an old list, because the ranges change over time. The addresses below are documentation placeholders, not Cloudflare ranges.
NGINX
# For example /etc/nginx/conf.d/cloudflare-realip.conf
# Must be loaded inside the http block; check your nginx.conf includes.

# One line per current Cloudflare range, IPv4 and IPv6.
# Placeholders only: replace with the real published list.
set_real_ip_from 192.0.2.0/24;
set_real_ip_from 2001:db8::/32;

# Header Cloudflare uses for the original visitor address
real_ip_header CF-Connecting-IP;
The security side matters here, so it is worth being explicit. set_real_ip_from is the only thing standing between you and spoofed addresses. Nginx only trusts the header when the connection comes from a listed range. If you skip it, or list 0.0.0.0/0, anyone can connect directly to your origin, send their own CF-Connecting-IP header, and pick whatever address they like. That lets them dodge your rate limit entirely or get an innocent address blocked. Only list ranges you actually control or trust. Some setups use real_ip_header X-Forwarded-For; with real_ip_recursive on; instead. That also works, and it is the usual choice when there is another proxy or load balancer between Cloudflare and Nginx, since you would then list that proxy's address in set_real_ip_from as well. For a plain Cloudflare-to-Nginx setup, CF-Connecting-IP is simpler because it holds a single address. If Nginx sits in front of Apache or PHP-FPM, this change fixes Nginx's view of the visitor. Whether the backend also sees the real address depends on what headers Nginx passes on and how the backend reads them, so check that separately.
Test the config with sudo nginx -t, reload Nginx, and look at the debug log again. remote_addr should now match the cf= value on requests coming through Cloudflare, and you should see many distinct addresses instead of a few. If your Nginx version supports it, you can add realip_remote_addr to the debug format to show the original connecting address (the Cloudflare edge) next to the restored visitor address. Rerun the error log commands from the first section. Rejections should now be spread across individual addresses, and the ones that repeat should be clients that really are sending too much traffic. If a legitimate visitor still gets limited after the fix, that points to the rate and burst values being too tight for how your pages load, which is a tuning question rather than an IP question. Two more checks are worth doing. Make sure your origin is not reachable directly in a way that bypasses Cloudflare, since direct visitors will be limited by their own IP, which is correct, but they also skip whatever protection you rely on at the edge. And once you are done, remove the temporary ipdebug log so it does not grow forever. The short version: when a rate limit behind any reverse proxy blocks real people, check which address the limiter is counting before you change the rate. If it is the proxy, teach Nginx to trust that proxy's header with set_real_ip_from and real_ip_header, keep the trusted list tight and current, and confirm in the logs that each visitor now gets their own bucket.