Restoring Real Visitor IPs with Cloudflare and Nginx

When you put a site behind Cloudflare, Nginx sees Cloudflare's proxy IPs as the source of every request — not your visitor's actual address. Without fixing this, your access logs show Cloudflare instead of real users, your rate limits throttle Cloudflare's IPs instead of attackers, and any IP-based access control stops working. The ngx_http_realip_module restores the real IP from Cloudflare's headers.

Check if the module is available

Most Nginx packages include ngx_http_realip_module by default:

nginx -V 2>&1 | tr ' ' '\n' | grep realip

If you see --with-http_realip_module, you're set. If not, install the full Nginx package (some minimal builds omit it):

# Debian/Ubuntu
apt install nginx-full

# RHEL/Fedora — the default nginx package usually includes it
dnf install nginx

Step 1: Configure the real-IP module

Create /etc/nginx/cloudflare.conf and include it in your nginx.conf at the http { } level:

# cloudflare.conf — include in nginx.conf http { } block

# Cloudflare IPv4 ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

# Cloudflare IPv6 ranges
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;

real_ip_header CF-Connecting-IP;

Include it in nginx.conf:

http {
    include cloudflare.conf;
    # ... rest of your config
}

After reloading Nginx, your $remote_addr and $binary_remote_addr variables now contain the real visitor IP — everything downstream (access logs, rate limiting, geo-IP lookups) uses the correct address.

Important: Cloudflare publishes their current IP ranges at cloudflare.com/ips. Ranges change as they expand. Check the list periodically and update your config — or set up the auto-update script in Step 3.


Step 2: Conditionally skip logging your own IPs

With real IPs restored, you can filter your own traffic from access logs — keeps them lean and focused on actual visitors:

map $remote_addr $log_ip {
    "127.0.0.1"     0;
    "::1"           0;
    "203.0.113.50"  0;  # Your home/office IPv4
    "2001:db8:abc::1" 0;  # Your home IPv6

    # Nginx maps don't support CIDR for ranges natively,
    # but you can use a regex for an IPv6 prefix:
    "~^2001:db8:abc:def:"  0;

    default         1;
}

Use $log_ip in your server blocks:

access_log /var/log/nginx/mysite.access.log combined if=$log_ip;

The regex approach for IPv6 prefixes works because Nginx's map directive supports case-insensitive regex matching with ~. For IPv4 subnets, list the specific IPs or the first few octets as a regex — but in practice, residential IPv4 changes often enough that you're better off listing your specific current address and updating it when it rotates.


Step 3: Automate IP range updates

Cloudflare occasionally adds new IP ranges. A cron job that fetches the current list and reloads Nginx keeps your config current without manual intervention:

#!/bin/bash
# /etc/cron.weekly/cloudflare_realip_update

# Fetch current IP ranges
CF_IPS=$(curl -s https://www.cloudflare.com/ips-v4)
CF_IPS6=$(curl -s https://www.cloudflare.com/ips-v6)

if [ -z "$CF_IPS" ] || [ -z "$CF_IPS6" ]; then
    echo "Failed to fetch Cloudflare IPs — skipping update" >&2
    exit 1
fi

# Write new config
cat > /etc/nginx/cloudflare.conf.tmp << 'HEADER'
# Cloudflare IPv4
HEADER

for ip in $CF_IPS; do
    echo "set_real_ip_from $ip;" >> /etc/nginx/cloudflare.conf.tmp
done

cat >> /etc/nginx/cloudflare.conf.tmp << 'HEADER'
# Cloudflare IPv6
HEADER

for ip in $CF_IPS6; do
    echo "set_real_ip_from $ip;" >> /etc/nginx/cloudflare.conf.tmp
done

cat >> /etc/nginx/cloudflare.conf.tmp << 'HEADER'
real_ip_header CF-Connecting-IP;
HEADER

# Only reload if the file actually changed
if ! diff -q /etc/nginx/cloudflare.conf /etc/nginx/cloudflare.conf.tmp > /dev/null 2>&1; then
    mv /etc/nginx/cloudflare.conf.tmp /etc/nginx/cloudflare.conf
    nginx -t && systemctl reload nginx
    echo "Cloudflare IP ranges updated and Nginx reloaded"
else
    rm /etc/nginx/cloudflare.conf.tmp
    echo "Cloudflare IP ranges unchanged"
fi
chmod +x /etc/cron.weekly/cloudflare_realip_update

The script fetches the live lists from Cloudflare, rebuilds the config file, and reloads Nginx only if something changed. Weekly is sufficient — Cloudflare doesn't update ranges often.


Step 4: Two IP variables — know which is which

Once the real-IP module is active, Nginx gives you two client-address variables — and getting them confused silently breaks your rate limiting:

VariableContains
$remote_addr / $binary_remote_addrThe real visitor IP — overwritten by the real-IP module from CF-Connecting-IP
$realip_remote_addrThe proxy IP — the original address before real-IP processing (Cloudflare's edge server)

$binary_remote_addr is the compact binary form of $remote_addr — the visitor IP after real-IP processing. This is the variable you want for per-visitor rate and connection limiting.

If you accidentally key a limit_req_zone on $realip_remote_addr, all visitors behind the same Cloudflare edge share one bucket. One bot burns the rate limit; legitimate visitors get throttled or 503'd. On a small site with only a few Cloudflare edge IPs, this silently collapses your rate limiting to a handful of shared buckets — indistinguishable from not having rate limiting at all.

# In nginx.conf http { } block — after including cloudflare.conf
limit_req_zone $binary_remote_addr zone=botstop:1m rate=1r/m;
limit_conn_zone $binary_remote_addr zone=phplimit:1m;

Make sure include cloudflare.conf; appears before any limit_req_zone or limit_conn_zone definitions — the real-IP module needs to be configured before Nginx evaluates the rate-limit keys.


Step 5: Verify it's working

Check your access log after a few page loads — you should see real visitor IPs, not Cloudflare addresses:

tail -f /var/log/nginx/mysite.access.log

If you're still seeing Cloudflare IPs, check that:

  • cloudflare.conf is included at the http { } level, not inside a server block
  • Cloudflare's SSL/TLS mode is set to "Full" or "Full (Strict)" — Flexible mode strips the CF-Connecting-IP header
  • You're not using another proxy layer in front of Cloudflare that overwrites the header

Where this fits

Restoring real visitor IPs is a prerequisite for most other security configurations on this site:

  • The fail2ban + nftables guide covers the same $binary_remote_addr vs $realip_remote_addr distinction in its production hardening section — getting this wrong there means fail2ban bans Cloudflare's IPs instead of attackers.
  • The WordPress on Nginx guide uses $binary_remote_addr for login-page rate limiting and PHP connection caps — both depend on real IPs being restored first.

Full reference: ngx_http_realip_module

Technical Audit Summary

This guide is maintained as part of a modular, SSL-first framework. Each configuration is audited for production stability and modern security standards.

Last Audit: May 2026
Environment: Debian Trixie (13)
Nginx: 1.30.2
PHP-FPM: 8.5.6

Compatibility: Tested against current stable releases. The ngx_http_realip_module is included in all standard Nginx builds.

2026-05-21: Critical fix in Step 4 — corrected rate-limiting variables from $realip_remote_addr to $binary_remote_addr. $realip_remote_addr preserves the proxy IP (Cloudflare's edge), not the visitor. Keying rate limits on it silently collapses all visitors behind the same Cloudflare edge into one rate-limit bucket — indistinguishable from not having rate limiting at all. Added a two-variable reference table explaining the distinction. Bumped stack versions (Nginx 1.30.1, PHP-FPM 8.5.6). Added cross-links to fail2ban guide and WordPress on Nginx guide.