When running a website behind a load balancer and Cloudflare, handling client IPs in Nginx can be tricky. Different setups use different headers to pass the real IP:

  1. Custom Domains via Cloudflare: Cloudflare sends the real client IP in the CF-Connecting-IP header.
  2. Direct CNAME to Load Balancer: The load balancer sets the real client IP in the X-Forwarded-For header.

What is the challenge? Nginx’s real_ip_header directive doesn’t support variables. This limitation means you can’t conditionally choose between headers like X-Forwarded-For and CF-Connecting-IP.


Understanging the problem

  • Cloudflare Users: Their connections come first through Cloudflare, which sets the CF-Connecting-IP header/
  • Non-Cloudflare Users: Their connections pass through the load balancer, which sets X-Forwarded-For.
  • Nginx’s limitation: real_ip_header accepts only one static value and does not support conditional evaluation.

For a detailed explanation of this limitation, check out this Medium post.


My Solution without extra modules

We can use Nginx’s map directive to dynamically decide which header to trust. Here’s how:

1. Get real_ip from X-Forwarded-For header

Since traffic anyways(even for CloudFlare users) goes through NLB, it sets X-Forwarded-For header. If NLB receives traffic directly from the user it gets user IP, if from CloudFlare - CloudFlare IP. So the first step get real_ip from NLB.

    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

set_real_ip_from is set to 10.0.0.0/8 block, because it’s a common practice to specify trusted proxies (like the NLB) to ensure only these proxies can influence the real_ip_header.

2. Use geo directive to detectl CloudFlare IPs

Use geo directive of ngx_http_geo_module module to create variable with values depending on the client IP address. The geo directive allows us to check the client’s IP address and assign a value to a variable ($use_x_cf_connecting_ip), depending on whether it belongs to Cloudflare.

        # Cloudflare
        geo $use_x_cf_connecting_ip {
            default 0;
            173.245.48.0/20 1;
            103.21.244.0/22 1;
            103.22.200.0/22 1;
            # ... and other CloudFlare IPs
        }

Cloudflare regularly updates its IP ranges, please be sure to use actual Cloudflare IP ranges

3. Define a map for Header Selection

The map directive evaluates the value of $use_x_cf_connecting_ip. If it’s 1, meaning the IP matches Cloudflare’s range, the config uses the CF-Connecting-IP header. Otherwise, it defaults to X-Forwarded-For.

map $use_x_cf_connecting_ip $real_ip {
    default       $http_x_forwarded_for; # Fallback to X-Forwarded-For
    "1"           $http_cf_connecting_ip; # Use CF-Connecting-IP if present
}

4. Use the Calculated IP for Backend and Logs

  • Pass the dynamically selected $real_ip to the backend.
  • Use it in Nginx logs for accurate tracking.
# Pass to backend
fastcgi_param REMOTE_ADDR $real_ip;

# Use in logs
log_format main '{"remote_ip": "$real_ip", "request": "$request"}';

Full Configuration Example

Here’s the full Nginx configuration with the solution implemented:

http {
        set_real_ip_from 10.0.0.0/8;
        real_ip_header X-Forwarded-For;
        real_ip_recursive on;

        # Cloudflare
        geo $use_x_cf_connecting_ip {
            default 0;
            173.245.48.0/20 1;
            103.21.244.0/22 1;
            103.22.200.0/22 1;
            # ... and other CloudFlare IPs
        }

        map $use_x_cf_connecting_ip $real_ip {
            default $http_x_forwarded_for;
            "1" $http_cf_connecting_ip;
        }
}

Closing Thoughts

If you’re running a hybrid setup like this, leveraging Nginx’s map and geo directives provides a clean and efficient way to handle client IPs. This approach ensures your logs, backend services, and application logic all receive accurate IP information.

Have you encountered similar challenges? Share your thoughts and solutions in the comments! Let’s keep learning and growing together. 🚀