The Mystery: Client IP is Always 127.0.0.1
You log into your backend application (Nextcloud, WikiJS, whatever). Check the access logs and every single request shows the client IP as 127.0.0.1 or localhost. But you know traffic’s coming from different places.
The reverse proxy is eating the original client IP.
This breaks geolocation, rate limiting, security logging, and anything that cares about where traffic came from. You need to fix it properly instead of just ignoring the problem.
Why This Happens
When a reverse proxy (Nginx, HAProxy, Caddy) forwards a request to your backend, it creates a new TCP connection. As far as the backend sees it:
- Connection comes from:
127.0.0.1(or the proxy’s IP) - Original client IP: Lost
The proxy needs to tell the backend where the request actually came from. It does this via an HTTP header: X-Forwarded-For.
The Correct Headers (All Three)
Reverse proxies should pass three headers to the backend:
X-Forwarded-For: client-ipX-Forwarded-Proto: http or https (which protocol was original)X-Forwarded-Host: original hostnameMost proxies don’t do this by default. You have to configure it.
Fix It in Nginx
This is the most common setup:
upstream backend { server 127.0.0.1:3000;}
server { listen 80; server_name example.com;
location / { proxy_pass http://backend;
# REQUIRED: Forward the original client IP proxy_set_header X-Forwarded-For $remote_addr;
# REQUIRED: Forward the original protocol proxy_set_header X-Forwarded-Proto $scheme;
# REQUIRED: Forward the original hostname proxy_set_header X-Forwarded-Host $server_name;
# Nice to have: Forward the port proxy_set_header X-Forwarded-Port $server_port;
# Remove the connection header (allows keepalive to the backend) proxy_set_header Connection ""; proxy_http_version 1.1; }}Every one of those proxy_set_header lines is doing something crucial. Don’t skip any of them.
Check If Nginx Is Actually Sending Them
# Connect directly to your backend (bypassing the proxy):curl -v http://localhost:3000/
# You'll see: X-Forwarded-For header is missing (because you're local)
# Now through the proxy:curl -v http://example.com/
# Check the backend logs to see what IP it receivedOr use tcpdump to spy on the headers:
sudo tcpdump -i lo port 3000 -A | grep -i "x-forwarded"Backend Configuration: How to Use These Headers
Node.js / Express
const express = require('express');const app = express();
// Trust the proxy:app.set('trust proxy', 1);
app.use((req, res, next) => { console.log("Client IP:", req.ip); // Now gives original IP console.log("User Agent:", req.get('user-agent')); next();});
app.listen(3000);That trust proxy line tells Express to read the X-Forwarded-For header instead of using the connection IP.
Django
# settings.py:SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')Now Django respects the X-Forwarded-Proto header.
Next.js / Vercel
import { headers } from 'next/headers';
export default function MyPage() { const headersList = headers(); const clientIP = headersList.get('x-forwarded-for');
return <div>Your IP: {clientIP}</div>;}Python Flask
from flask import Flask, requestfrom werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
# Trust one level of proxy (Nginx is one level)app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
@app.route('/')def hello(): client_ip = request.remote_addr return f'Your IP: {client_ip}'The Dangerous Part: Trusting the Header Blindly
Here’s the problem: X-Forwarded-For is just an HTTP header. It’s not validated. If you have zero security:
- Remove the reverse proxy
- A malicious client directly connects to port 3000
- They can set their own
X-Forwarded-For: 1.2.3.4 - Your app thinks they’re coming from
1.2.3.4
Solution: Only trust the header if it comes from a known proxy:
# In Nginx, set a different header that the backend trusts:proxy_set_header X-Forwarded-For $remote_addr;proxy_set_header X-Real-IP $remote_addr;
# Add your proxy IP to the header only if it's trusted:# (Nginx does this automatically in newer versions)In your backend, trust X-Real-IP only if it came through Nginx:
# Django:def get_client_ip(request): x_real_ip = request.META.get('HTTP_X_REAL_IP') x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
# Only trust X_REAL_IP if connection is from localhost (Nginx): if request.META.get('REMOTE_ADDR') == '127.0.0.1': return x_real_ip or x_forwarded_for.split(',')[0]
return request.META.get('REMOTE_ADDR')Better: If your proxy is on the same machine, just trust all headers (they can’t be forged from outside).
Fix It in Caddy
Caddy gets this right by default:
:80 { reverse_proxy localhost:3000 { header_upstream X-Forwarded-For {remote_host} header_upstream X-Forwarded-Proto {http.request.proto} }}Actually, Caddy adds these automatically. You don’t even need to configure them.
Fix It in HAProxy
backend backend_servers server app1 127.0.0.1:3000
# Forward the real IP: http-reuse safe option forwardfor http-send-name-header X-Forwarded-HostTest Everything End-to-End
# 1. Make a request through the proxycurl -H "User-Agent: Testing" http://example.com/api/info
# 2. Check backend logs:tail -f /var/log/app/access.log
# You should see:# Client IP: [your actual IP, not 127.0.0.1]# Headers: X-Forwarded-For: [your IP], X-Forwarded-Proto: http
# 3. If you still see 127.0.0.1:# - Check Nginx config for the proxy_set_header lines# - Verify the backend is reading the header correctly# - Check backend logs (not access logs) for what it receivedReal Example: Nextcloud Behind Nginx
Nextcloud needs these headers to work correctly (geolocation, rate limiting):
upstream nextcloud { server 127.0.0.1:8080;}
server { listen 80; server_name cloud.example.com;
location / { proxy_pass http://nextcloud;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $server_name; proxy_set_header X-Forwarded-Port $server_port;
# Nextcloud also needs Host: proxy_set_header Host $host;
# And this one for WebDAV: proxy_set_header Connection "upgrade"; }}Then in Nextcloud’s config:
'trusted_proxies' => ['127.0.0.1'], // Only trust from local Nginx'overwriteprotocol' => 'https',Done. Now Nextcloud logs show real client IPs, geolocation works, and rate limiting applies per-actual-client, not per-proxy.
The Bottom Line
This is one of those things that looks optional but breaks geolocation, rate limiting, and security logging. It takes five minutes to configure correctly. Don’t skip it.
Reverse proxy headers aren’t magic. They’re just HTTP headers that say “this request originally came from here”. Configure your proxy to send them, configure your backend to read them, and suddenly your app knows who’s actually connecting.