Your Nginx access logs are flooded with the same five edge proxy IP addresses instead of the actual visitors hitting your site. In this guide, we’ll configure ngx_http_realip_module to strip away the proxy layer and restore real client IPs across your access logs, FastCGI apps, and rate limiters.
I learned this lesson the hard way after rolling out edge caching for a client. A botnet kicked off a brute-force attack on wp-login, Fail2ban triggered on our origin, and it immediately jailed an entire StackPath edge server. That single ban blackholed half our legitimate visitors because Nginx thought every inbound connection came from that one CDN IP. If you run WordPress, Node, or any upstream app behind an edge cache, getting this sorted is mandatory before you touch rate limiting or security jails.

Why Nginx Logs the CDN Proxy Instead of the Client
Once you put a CDN in front of your stack, visitors stop talking to your server directly. A browser opens a TCP handshake with the CDN’s closest edge node. That edge node terminates TLS, pulls from cache or fetches from your origin, and opens a brand-new TCP connection back to your Nginx box.
Out of the box, Nginx pulls the client IP straight from the TCP socket variable: $remote_addr. Because the CDN node opened that socket, $remote_addr becomes the CDN’s IP. Suddenly, every single hit in your logs looks like it came from your CDN provider.
The CDN does pass the real visitor IP along, usually tucked into an HTTP request header like X-Forwarded-For, or provider-specific headers like CF-Connecting-IP. You can read up on the header mechanics in the MDN documentation for X-Forwarded-For.
Here’s the catch: trusting raw headers in your application code without validation is dangerous. Anyone with curl can pass X-Forwarded-For: 1.1.1.1 and lie about who they are. The ngx_http_realip_module fixes this cleanly. It checks the actual TCP connection against a list of trusted CDN subnets, rewrites $remote_addr to the verified client IP, and discards header spoofing attempts.
Check If ngx_http_realip_module Is Compiled into Nginx
Before touching config files, verify your Nginx binary actually includes the Real IP module. Most distro builds on Ubuntu, Debian, AlmaLinux, and Rocky include it, but stripped-down containers and custom builds sometimes omit it.
Run this in your terminal to check your build arguments:
nginx -V 2>&1 | grep --color=auto 'http_realip_module' If you see --with-http_realip_module highlighted, you’re good. If grep comes back empty, you’re on a stripped build. On Ubuntu or Debian, swapping to nginx-full or grabbing the official packages from nginx.org fixes it right away.
If you followed our walkthrough to deploy Express.js on Hostinger VPS, your standard Nginx install already has this module built in and ready to roll.
The Core Directives: set_real_ip_from and real_ip_header
The module centers around three directives you can drop into http, server, or location blocks. If you want every detail, check the official ngx_http_realip_module documentation.
- set_real_ip_from: An IP or CIDR block of trusted reverse proxies. Nginx only rewrites
$remote_addrif the TCP socket matches this list. - real_ip_header: Which header contains the real IP (usually
X-Forwarded-For). - real_ip_recursive: Controls how Nginx parses comma-separated proxy lists inside
X-Forwarded-For.
Here is what a baseline config looks like in /etc/nginx/conf.d/realip.conf:
# /etc/nginx/conf.d/realip.conf # Trust local loopback reverse proxies
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1; # Trust CDN subnets (example blocks)
set_real_ip_from 198.51.100.0/24;
set_real_ip_from 203.0.113.0/24; # Extract IP from header
real_ip_header X-Forwarded-For;
real_ip_recursive on; Dropping this in /etc/nginx/conf.d/ keeps things global so every virtual host inherits the trusted proxy ranges automatically.
Why real_ip_recursive on Is Mandatory
This is where most setups get burned by spoofing or broken logging. Every proxy in a chain appends addresses to X-Forwarded-For: client, proxy1, proxy2.
Say an attacker crafts an HTTP request with a fake header:
X-Forwarded-For: 8.8.8.8 When that request hits your CDN node (at 198.51.100.15), the CDN appends the attacker’s actual IP (203.0.113.99) before handing the request off to your origin:
X-Forwarded-For: 8.8.8.8, 203.0.113.99 If you leave real_ip_recursive off (which, annoyingly, is Nginx’s default!), Nginx grabs the very first IP in the list. It sees 8.8.8.8, treats it as the client, and the attacker just successfully spoofed their IP past your firewall.
When you set real_ip_recursive on, Nginx reads the header from right to left. It checks 198.51.100.15 (matches your set_real_ip_from list), discards it, checks 203.0.113.99, sees it is not trusted, stops there, and assigns 203.0.113.99 to $remote_addr. The spoofed 8.8.8.8 gets ignored completely.
Configuring Real IP for CDN Subnets
To wire this up, you need the public CIDR blocks published by your CDN. Whatever you do, do not set set_real_ip_from 0.0.0.0/0;. That turns your origin into an open door where any client can spoof arbitrary IPs.
I prefer keeping CDN ranges in a standalone snippet file instead of cluttering server blocks. Let’s create one:
sudo nano /etc/nginx/snippets/cdn-realip.confPaste the subnets provided by your CDN provider. Here’s a clean template you can adapt for Cloudflare, StackPath, Fastly, or your own edge fleet:
# Edge CDN IPv4 Ranges
set_real_ip_from 151.139.128.0/18;
set_real_ip_from 151.139.192.0/19;
set_real_ip_from 151.139.224.0/20; # Edge CDN IPv6 Ranges (if applicable)
set_real_ip_from 2a02:26f0::/32;
set_real_ip_from 2606:4700::/32; # Use standard proxy header
real_ip_header X-Forwarded-For;
real_ip_recursive on; Now, pull that snippet into your site’s server block inside /etc/nginx/sites-available/your-site.com:
server { listen 80; listen 443 ssl http2; server_name example.com www.example.com; # Import CDN Real IP rules include /etc/nginx/snippets/cdn-realip.conf; root /var/www/html; index index.php index.html; location / { try_files $uri $uri/ /index.php?$args; } location ~ .php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; fastcgi_param REMOTE_ADDR $remote_addr; }
} Notice that fastcgi_param REMOTE_ADDR $remote_addr; now hands the cleaned-up client IP down to PHP, WordPress, or your upstream app. While you’re working in your server blocks, you might also want to see how to fix 413 request entity too large errors if your site handles chunky uploads through the proxy.
Testing the Config and Verifying Logged IPs
Never reload Nginx blindly. A misplaced comma or broken CIDR mask will drop your web server the second you reload.
Test your config syntax first:
sudo nginx -t Once you see syntax is ok and test is successful, reload Nginx:
sudo systemctl reload nginx Now verify that real IPs are hitting the disk. Make sure your access_log uses the standard combined format, which begins with $remote_addr:
log_format combined '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent"';Tail the log while loading your site from your phone (on mobile data, off the office Wi-Fi):
tail -f /var/log/nginx/access.log Before this change, every line started with the CDN’s edge address (like 151.139.128.44 - - [10/May/2024:14:22:01 +0000]). Now, you should immediately see your phone’s carrier IP (something like 73.189.42.110 - - [10/May/2024:14:22:05 +0000]).
Fixing Rate Limiting and Fail2ban
With real IPs in place, rate limits and security jails finally work as intended. If you define a limit_req_zone before fixing real IPs, every single visitor sharing that CDN edge node pools into the same bucket. You end up throwing 503 errors at paying customers during normal peak hours.
Here is how to set up origin rate limiting on real client IPs:
# Defined in http context
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; server { listen 443 ssl http2; server_name api.example.com; include /etc/nginx/snippets/cdn-realip.conf; location /v1/auth/ { # Limit individual visitors to 10 requests/sec with burst limit_req zone=api_limit burst=20 nodelay; proxy_pass http://127.0.0.1:3000; }
} Because ngx_http_realip_module rewrites $binary_remote_addr under the hood, your 10MB memory zone tracks individual human visitors instead of the CDN node. If you want to lock the origin down further, pair this with our guide to restrict origin server traffic to CDN shield IPs using UFW.
Automating CDN Subnet Updates via Cron
CDNs adjust their IP ranges over time. If your CDN spins up a new PoP on an unlisted subnet, requests coming through those nodes silently fall back to showing the edge proxy IP again.
We can prevent that with a quick shell script that pulls current ranges, writes a fresh config, tests it, and reloads Nginx.
Create /usr/local/bin/update-cdn-ips.sh:
#!/usr/bin/env bash
set -euo pipefail OUTPUT_FILE="/etc/nginx/snippets/cdn-realip.conf"
TEMP_FILE="/tmp/cdn-realip.conf.tmp" # Header configuration
cat < "$TEMP_FILE"
# Auto-generated CDN Real IP rules
# Do not edit manually EOF # Example pulling Cloudflare IPv4 ranges (replace URL for your CDN provider)
curl -sSL https://www.cloudflare.com/ips-v4 | while read -r ip; do if [[ -n "$ip" ]]; then echo "set_real_ip_from $ip;" >> "$TEMP_FILE" fi
done # Append directives
cat <> "$TEMP_FILE" real_ip_header X-Forwarded-For;
real_ip_recursive on;
EOF # Validate before moving to production
if nginx -t -c /etc/nginx/nginx.conf > /dev/null 2>&1; then mv "$TEMP_FILE" "$OUTPUT_FILE" systemctl reload nginx echo "CDN IPs updated and Nginx reloaded at $(date)"
else echo "Nginx validation failed. Discarding updates." rm -f "$TEMP_FILE" exit 1
fiMake it executable:
sudo chmod +x /usr/local/bin/update-cdn-ips.shSet up a weekly root cron job to run it automatically:
echo "0 3 * * 0 root /usr/local/bin/update-cdn-ips.sh >> /var/log/cdn-update.log 2>&1" | sudo tee /etc/cron.d/update-cdn-ipsThat takes care of network drift so you don’t have to manually chase IP list changes when providers expand their edge infrastructure.
Frequently Asked Questions
Should I use X-Forwarded-For or CF-Connecting-IP?
If you’re exclusively on Cloudflare, CF-Connecting-IP is dead simple because Cloudflare guarantees it contains only one IP. But X-Forwarded-For combined with real_ip_recursive on; is standard, works across multi-CDN setups (Fastly, AWS CloudFront, StackPath), and adheres to proxy conventions outlined in RFC 7239.
Why does $_SERVER[‘REMOTE_ADDR’] still show the CDN IP in PHP?
Check where you define fastcgi_param REMOTE_ADDR $remote_addr;. It must come after your include fastcgi_params; or snippets/fastcgi-php.conf directive. If an earlier include sets REMOTE_ADDR before Nginx runs the Real IP filter, PHP ends up reading the socket IP instead of the rewritten variable.
Can I set set_real_ip_from 0.0.0.0/0 to trust all proxies?
Never. Setting 0.0.0.0/0 tells Nginx that literally any client on the internet is an authentic proxy. Anyone can send a header like X-Forwarded-For: 127.0.0.1 and your backend will treat them as an internal admin, blowing a massive hole in your auth and access control.
Does ngx_http_realip_module add latency to requests?
Not noticeably. Checking an in-memory CIDR table takes fractions of a microsecond per request. The module runs during Nginx’s HTTP preconfiguration phase, well before any disk I/O, cache lookups, or upstream FastCGI connections happen.
Where to Go Next
With accurate visitor logs and working rate limits, your origin security is solid. The next logical step is locking down cache TTLs. Take a look at our guide on how to configure WordPress browser cache headers and CDN edge caching on Nginx to cut origin load while serving fresh content to your visitors.

