X-Real-IP header confusion in nginx: fix the wrong IP
Every request in your Laravel log comes from 172.18.0.1, and request()->ip() returns that same address for every visitor, even though your front nginx clearly has proxy_set_header X-Real-IP $remote_addr;. That's the classic x-real-ip header confusion in nginx: the header is being sent, but nothing on the receiving side reads it.
Short answer: proxy_set_header X-Real-IP $remote_addr only sends the header to the next hop. It doesn't change what that hop sees as the client address. The receiving nginx needs the realip module: set_real_ip_from with the proxy's address and real_ip_header X-Real-IP. Then $remote_addr, and PHP's REMOTE_ADDR, become the visitor's IP.
Why x-real-ip header confusion in nginx happens
Two directives look almost the same and do opposite jobs:
proxy_set_header X-Real-IP $remote_addr;is outgoing. It writes a header onto the request nginx forwards upstream.real_ip_header X-Real-IP;is incoming. It tells nginx which header on the arriving request to trust as the client address, and it only works for connections from addresses listed inset_real_ip_from.
X-Real-IP isn't a formal standard. It's a convention, and nothing reads it automatically. Not nginx (until you configure realip), not PHP, and not Laravel either. Laravel, through Symfony's trusted proxy support, reads X-Forwarded-For, never X-Real-IP.
Here's the running example for the rest of this guide, because it's the setup that produces this bug most often on a VPS:
- Visitor connects to the host nginx, which terminates TLS.
- Host nginx proxies to
127.0.0.1:8080, published by a Docker container. - Inside the container, a second app nginx passes PHP to php-fpm running Laravel.
The app nginx sees the connection coming from the Docker bridge gateway (172.18.0.1 here, yours may differ), so that's what lands in $remote_addr and REMOTE_ADDR.
Fixing nginx X-Real-IP showing the proxy IP, step by step
Step 1: Log what each hop actually receives
Don't guess which hop drops the address. Add a debug log format to the http block of both nginx instances:
log_format ipdebug '$remote_addr xreal="$http_x_real_ip" '
'xff="$http_x_forwarded_for" "$request"';
access_log /var/log/nginx/ipdebug.log ipdebug;
Reload, make one request from your browser, then read the log on the app nginx:
docker exec app tail -n 5 /var/log/nginx/ipdebug.log
In the broken setup you'll see something like 172.18.0.1 xreal="203.0.113.50" .... The real IP is right there in the header. The app nginx just isn't using it. If xreal is empty ("-"), the problem is on the host nginx instead, so go to Step 2.
Step 2: Send the headers correctly from the host nginx
On the host, the proxy block should look like this:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
The gotcha I'd warn any colleague about: proxy_set_header inheritance. If your server block sets these headers and a location block sets even one proxy_set_header of its own, the location inherits none of the server-level ones. That's the usual reason xreal shows up empty for one path but not another. Put the full set in every location that proxies, or move them into a snippet and include it.
Also never write proxy_set_header X-Real-IP $http_x_real_ip; on a public-facing nginx. That forwards whatever the visitor typed into the header, so anyone can claim any IP.
Step 3: Enable realip on the receiving nginx
First confirm the module exists:
docker exec app nginx -V 2>&1 | grep -o with-http_realip_module
Most distro packages and the official nginx Docker image include it. If the command prints nothing, you need a build that has it.
Find the subnet the host connects from:
docker network inspect app_default --format '{{(index .IPAM.Config 0).Subnet}}'
Then add this to the app nginx, in the http or server block:
set_real_ip_from 172.18.0.0/16;
real_ip_header X-Real-IP;
Use the subnet your command printed. Trust only that range. set_real_ip_from 0.0.0.0/0 makes every visitor able to spoof their IP, which defeats rate limiting and any IP-based ban.
Step 4: If Cloudflare sits in front, fix the host first
With Cloudflare proxying, the host nginx's own $remote_addr is a Cloudflare edge IP, so Step 2 faithfully forwards the wrong address. The host needs realip too. Generate the config from Cloudflare's published ranges rather than copying a list from a blog:
{
for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
echo "set_real_ip_from $ip;"
done
echo "real_ip_header CF-Connecting-IP;"
} | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf
On Debian and Ubuntu, conf.d/*.conf is included inside http, which is the right context. Re-run this occasionally, since the ranges can change. If Cloudflare is also interfering with your front end, the Cloudflare JS challenge fix for Laravel SPAs covers the other half of that setup.
If you have several proxies and can't rely on a single header (say, a load balancer plus Cloudflare), use X-Forwarded-For with recursion instead:
real_ip_header X-Forwarded-For;
real_ip_recursive on;
With recursion on, nginx walks the list from the right and skips every address in set_real_ip_from, stopping at the first untrusted one.
Step 5: Make Laravel agree
Once the app nginx rewrites $remote_addr, PHP gets the right value automatically, because the stock fastcgi_params file already contains:
fastcgi_param REMOTE_ADDR $remote_addr;
So request()->ip() is correct with no PHP changes. Check that your PHP location includes fastcgi_params (or fastcgi.conf) and doesn't override REMOTE_ADDR later.
The alternative is to skip realip and let Laravel trust the proxy via X-Forwarded-For. In Laravel 11 and later, that's in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '172.18.0.0/16');
})
In older versions, set $proxies in app/Http/Middleware/TrustProxies.php. My preference is realip in nginx, because then the access log, limit_req and deny rules all see the same IP as your app. Opinion, not rule.
Step 6: Reload both servers
sudo nginx -t && sudo systemctl reload nginx
docker exec app nginx -t && docker exec app nginx -s reload
If nginx -t complains about an unknown directive on set_real_ip_from, the realip module from Step 3 is missing.
Verify the fix with a spoofed header
Add a temporary route to routes/web.php:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/ip-check', function (Request $request) {
return response()->json([
'ip' => $request->ip(),
'remote_addr' => $request->server('REMOTE_ADDR'),
'x_real_ip' => $request->header('X-Real-IP'),
'xff' => $request->header('X-Forwarded-For'),
]);
});
Now try to lie about your address:
curl -s -H "X-Real-IP: 1.2.3.4" https://example.com/ip-check
ip should be your real public IP, not 1.2.3.4 and not 172.18.0.1. The host nginx overwrites the spoofed header with its own $remote_addr, which is exactly what you want.
To test the origin directly and bypass Cloudflare, pin the hostname to your VPS IP as shown in setting a DNS server for a curl request. If that test throws curl: (35) ... wrong version number, you're hitting a plain HTTP port with HTTPS, and this nginx curl (35) fix sorts it out.
Delete the route once you're done. It exposes request headers to anyone.
Next steps once the real IP is flowing
Remove the ipdebug log, or it'll grow forever. Then revisit anything that keys on IP: Laravel's rate limiters, fail2ban jails and nginx allow/deny rules. They were all seeing one address before, which means rate limits were effectively shared by every visitor. If your stack has more layers than this and you'd like a second pair of eyes, contact HeadsNTail.
FAQ
What is the difference between X-Real-IP and X-Forwarded-For?
X-Real-IP holds a single address, set by the proxy closest to the visitor. X-Forwarded-For is a comma-separated list where each proxy appends the address it received the request from. X-Forwarded-For is more widely supported by frameworks, while X-Real-IP is simpler to read in nginx.
Is the X-Real-IP header safe to trust?
Only when it comes from a proxy you control. Any client can send the header, so trust it solely for connections from addresses in set_real_ip_from, and make sure your public-facing nginx overwrites it with $remote_addr.
Why does $remote_addr show 127.0.0.1 in nginx?
Because the request reached that nginx from another process on the same machine, usually a local reverse proxy or tunnel. $remote_addr is always the TCP peer. Add set_real_ip_from 127.0.0.1; (and ::1 if the proxy connects over IPv6) plus real_ip_header to replace it.
Does Laravel read the X-Real-IP header?
No. Laravel's trusted proxy handling reads X-Forwarded-For and related forwarded headers, not X-Real-IP. Either fix REMOTE_ADDR with nginx realip, or configure TrustProxies so Laravel uses X-Forwarded-For.
Do I need real_ip_recursive?
Only with real_ip_header X-Forwarded-For and more than one trusted proxy in the chain. Without it, nginx takes the last address in the list, which is often your own load balancer rather than the visitor.
With the x-real-ip header confusion in nginx sorted, check X-Forwarded-Proto next if Laravel still generates http:// links behind your proxy.


