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.5Note: 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
| Component | What it uses the IP for | Consequence when it only sees the proxy IP |
|---|---|---|
Rate limiting (limit_req, limit_conn) | Counting requests per IP | All users share one quota: a single bot gets the whole site 429s, or conversely, the bot hides in the crowd |
| fail2ban | Banning IPs that misbehave | Banning a proxy IP also blocks many real users who come through that proxy |
Laravel throttle | Limiting 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, reports | Incident investigation, statistics | No idea who is attacking, no way to filter bots |
| Application | Storing order IPs, fraud prevention, geo-IP | Meaningless 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_addrstays as is.real_ip_header: the header that carries the real client IP, usuallyX-Forwarded-For,X-Real-IPorCF-Connecting-IP.real_ip_recursive: whenoff(the default), it takes the last address in the header. Whenon, 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 is10.0.0.5, still the proxy IP.real_ip_recursive on:10.0.0.5is skipped because it is trusted, and it stops at203.0.113.7. The address198.51.100.9on the left is ignored, and that is correct: the left part ofX-Forwarded-Foris 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:
- Check that nginx has
http_realip_module; if not, skip the machine and log an error. - Back up
nginx.conf, remove every oldset_real_ip_from,real_ip_headerandreal_ip_recursiveline, then insert a single block before the include line. If this runs in Jenkins Groovy, process the string in Groovy instead of sendingsedremotely, because\/and\*are easily misinterpreted before they reach the shell. - 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
serverblock declares its ownset_real_ip_from, overriding thehttplevel. - Clients can forge the real client IP: the trusted range is too broad, or the application reads the first element of
X-Forwarded-Foritself. - 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 -tpasses. - 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.
