A request from a browser to a website can pass through several hops: a CDN such as Cloudflare, a balancer, and only then the minion that runs the application. Each hop is a proxy, and each proxy replaces the source address with its own. Without any configuration, nginx on the minion sees every request coming from the balancer's IP, and Laravel treats every user as the same person. This guide explains why you must recover the real client IP, how nginx's realip module works, sample configuration for each tier, how to verify it, and the most common mistakes.

1. Overview: where the real client IP gets lost

In the three-tier Cloud VPS model, the balancer accepts connections from the Internet and opens a new connection to the minion over the private network. To nginx on the minion, the directly connected party is the balancer, so $remote_addr is always the balancer's private IP, for example 10.0.0.5. The real client IP is not entirely lost: the balancer copies it into a header when forwarding, as configured in The Balancer tier: Nginx reverse proxy, SSL offload and domain routing:

# balancer: location / of each site
proxy_set_header X-Real-IP       $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for takes the X-Forwarded-For header sent by the client (if any) and appends $remote_addr to the end. So the real client IP is in the header, but nginx on the minion does not read that header by default. Everything that uses $remote_addr on the minion is wrong: the access log, allow/deny, and most importantly the REMOTE_ADDR variable passed to PHP-FPM.

When the domain sits behind Cloudflare, there is one more hop. Now even the balancer cannot see the real client IP: $remote_addr on the balancer is the IP of the Cloudflare server closest to the user.

User 203.0.113.7
   │
   ▼  Cloudflare adds CF-Connecting-IP: 203.0.113.7
Cloudflare edge (IP within Cloudflare's published ranges)
   │
   ▼  $remote_addr = Cloudflare IP
balancer ── X-Forwarded-For: 203.0.113.7, <Cloudflare IP>
   │
   ▼  $remote_addr = 10.0.0.5 (balancer)
minion  ── without realip: every request "comes from" 10.0.0.5

Note: the 203.0.113.x and 198.51.100.x addresses in this article belong to ranges reserved for documentation and are used for illustration only.

2. Why a wrong real client IP is dangerous

ComponentWhat it uses the IP forConsequence when it only sees the proxy IP
Rate limiting (limit_req, limit_conn)Counting requests per IPAll users share one quota: a single bot gets the whole site 429s, or conversely, the bot hides in the crowd
fail2banBanning IPs that misbehaveBanning a proxy IP also blocks many real users who come through that proxy
Laravel throttleLimiting failed logins and form submissions by $request->ip()One person mistypes a password several times and everyone else is locked out of login too
Access logs, reportsIncident investigation, statisticsNo idea who is attacking, no way to filter bots
ApplicationStoring order IPs, fraud prevention, geo-IPMeaningless data, nothing but internal IPs

For example, in Standardizing Nginx security: security snippets and connection limits, limit_req is keyed on $binary_remote_addr on the balancer. For a site reached straight from the Internet, that is the real client IP. For a site behind Cloudflare where the balancer has no realip yet, it is a Cloudflare IP, so a "per IP" threshold actually applies to a whole group of users.

3. The mechanism: nginx's realip module

The ngx_http_realip_module replaces the value of $remote_addr with an address taken from a header, but only when the request comes from a trusted proxy. The module ships with Debian's nginx package (check with nginx -V 2>&1 | grep http_realip_module). Three directives to know:

  • set_real_ip_from: the trusted address ranges. For requests from anywhere else, the header is ignored and $remote_addr stays as is.
  • real_ip_header: the header that carries the real client IP, usually X-Forwarded-For, X-Real-IP or CF-Connecting-IP.
  • real_ip_recursive: when off (the default), it takes the last address in the header. When on, it walks from right to left and takes the first address that is not in a trusted range.

Example with the header X-Forwarded-For: 198.51.100.9, 203.0.113.7, 10.0.0.5 and set_real_ip_from 10.0.0.0/24:

  • real_ip_recursive off: the result is 10.0.0.5, still the proxy IP.
  • real_ip_recursive on: 10.0.0.5 is skipped because it is trusted, and it stops at 203.0.113.7. The address 198.51.100.9 on the left is ignored, and that is correct: the left part of X-Forwarded-For is sent by the client itself and may be forged.

This is the key security point: never take the leftmost address of X-Forwarded-For, and never set set_real_ip_from 0.0.0.0/0. Doing so lets anyone declare their own real client IP and dodge rate limiting and fail2ban with a single header.

4. Real client IP configuration on the minion: trust only the balancer

On the minion, the only trustworthy proxy is the balancer on the private network. Put the configuration in the http block of /etc/nginx/nginx.conf, before the include lines:

http {
    # ...
    # === Real IP from the balancer ===
    set_real_ip_from 10.0.0.0/24;          # private range of the balancers
    real_ip_header   X-Forwarded-For;
    real_ip_recursive on;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

Once applied, $remote_addr on the minion is the real client IP for every site that goes straight to the balancer. PHP-FPM receives the correct REMOTE_ADDR, so Laravel on PHP-FPM gets the right $request->ip() without any code changes. Keep an extra "$http_x_forwarded_for" column at the end of the log format so that investigations can still see the full original proxy chain.

Keeping the configuration in sync across many minions

With many minions, the realip configuration drifts easily: some machines have it but in different places, others do not have it at all. The sync script should do exactly one thing on each machine:

  1. Check that nginx has http_realip_module; if not, skip the machine and log an error.
  2. Back up nginx.conf, remove every old set_real_ip_from, real_ip_header and real_ip_recursive line, then insert a single block before the include line. If this runs in Jenkins Groovy, process the string in Groovy instead of sending sed remotely, because \/ and \* are easily misinterpreted before they reach the shell.
  3. Run nginx -t. On failure, copy the backup back; on success, systemctl reload nginx.

An easily missed detail: if a server block declares its own set_real_ip_from, the value at the server level completely replaces the value inherited from http. Keeping everything in one place in nginx.conf and declaring nothing extra in site configuration files avoids the situation where one site gets the real client IP right and another does not.

Laravel: TrustProxies when there is a proxy inside the application

Laravel Octane sites are a special case. The minion's nginx uses proxy_pass to FrankenPHP on 127.0.0.1, so from the application's point of view, the connecting party is the local machine itself. Laravel only reads X-Forwarded-For and X-Forwarded-Proto when the proxy is on the trusted list of the TrustProxies middleware. Following the Laravel guide to configuring trusted proxies, trust only the exact proxy address:

// app/Http/Middleware/TrustProxies.php (Laravel 10 and earlier)
protected $proxies = ['127.0.0.1'];

// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: ['127.0.0.1']);
})

Avoid setting $proxies = '*' for convenience: on a PHP-FPM site that receives requests directly from nginx, it lets clients forge the header. Because the minion's nginx has already recovered the real client IP before forwarding, the X-Forwarded-For that Laravel receives from 127.0.0.1 already has the real client IP in the last position.

5. The balancer: trusting Cloudflare's IP ranges

The minion configuration only solves half the problem. For domains that go through Cloudflare, if the balancer has no realip, $proxy_add_x_forwarded_for appends the Cloudflare IP to the end of the header, and the minion stops at the Cloudflare IP because that address is not in its trusted range. Every balancer that serves domains behind Cloudflare needs an extra file:

# /etc/nginx/conf.d/00-cloudflare-realip.conf on the balancer
# List taken from Cloudflare's IP ranges page - must be updated regularly
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;
# ... every IPv4 and IPv6 range Cloudflare publishes ...
set_real_ip_from 2400:cb00::/32;

real_ip_header CF-Connecting-IP;

Why CF-Connecting-IP rather than X-Forwarded-For: this header contains exactly one address, set by Cloudflare, so there is no multi-IP chain to worry about. And because set_real_ip_from only includes Cloudflare's ranges, any client that connects straight to the balancer and sends a forged CF-Connecting-IP is ignored. Domains that do not go through Cloudflare are unaffected, because their requests do not come from these ranges.

Once the balancer has realip, $remote_addr on the balancer is the real client IP, $proxy_add_x_forwarded_for appends the real client IP to the end of the header, and the minion with the section 4 configuration picks it up correctly with no further changes. The list published on Cloudflare's IP ranges page changes over time, so this file should be regenerated periodically by an automated task rather than copied by hand once:

#!/bin/sh
# regenerate the realip file from Cloudflare's list, reload only if nginx -t passes
OUT=/etc/nginx/conf.d/00-cloudflare-realip.conf
TMP=$(mktemp)
{
  for u in https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6; do
    curl -fsS "$u" | sed 's/^/set_real_ip_from /; s/$/;/'
  done
  echo "real_ip_header CF-Connecting-IP;"
} > "$TMP" || exit 1
cp "$OUT" "$OUT.bak" 2>/dev/null
mv "$TMP" "$OUT"
nginx -t && systemctl reload nginx || cp "$OUT.bak" "$OUT"

6. Verifying the real client IP

# on the minion: the first column must be the real client IP, not 10.0.0.x
tail -f /var/log/nginx/example.com.access | awk '{print $1}'

# forge the header from an outside machine: the logged IP must be your machine's IP, not 198.51.100.1
curl -s -H 'X-Forwarded-For: 198.51.100.1' https://example.com/ -o /dev/null

# in Laravel (tinker, or a temporary route enabled only while debugging)
Route::get('/_ip', fn (Request $r) => [$r->ip(), $r->header('X-Forwarded-For')]);

For a domain behind Cloudflare, if the minion log still records IPs from Cloudflare's ranges, the balancer has not loaded the realip file.

7. Common mistakes and how to fix them

  • The log still shows only balancer IPs: the realip block lives in a file that is not loaded. Check with nginx -T | grep -n real_ip.
  • The real client IP is replaced by a Cloudflare IP: the balancer does not trust Cloudflare's ranges yet, or the list is outdated. Regenerate the file as in section 5.
  • One site is right, another is wrong: some server block declares its own set_real_ip_from, overriding the http level.
  • Clients can forge the real client IP: the trusted range is too broad, or the application reads the first element of X-Forwarded-For itself.
  • Laravel Octane returns 127.0.0.1: TrustProxies is missing 127.0.0.1.

8. Checklist

  • Each proxy hop trusts only the hop right before it: the minion trusts the balancer, the balancer trusts Cloudflare, the application trusts local nginx.
  • Configure realip before enabling rate limiting, fail2ban or throttle: these layers are meaningless, even harmful, if the real client IP is wrong.
  • Configure it in one place: the realip block lives in nginx.conf, not scattered across sites.
  • Update Cloudflare's ranges automatically, and reload only when nginx -t passes.
  • Test with a forged header after every proxy configuration change.

Next article

One PHP-FPM pool per website: ending max_children errors. Why a single shared www pool for many sites lets one slow site drag the whole minion down with it, and how to split out a dedicated pool for each site.