I Added Real IP Support to Nginx and Killed My VLESS Node
I Added Real IP Support to Nginx and Killed My VLESS Node
It was 2 AM and every client was dead. v2rayN showed nothing but red. sing-box kept spitting out parse VLESS header failed.
I hadn't touched the proxy config. I'd added three lines to nginx:
set_real_ip_from 0.0.0.0/0;, real_ip_header X-Forwarded-For;, and a proxy_protocol on;.
The intent was reasonable enough — I wanted real visitor IPs in my logs instead of a wall of 127.0.0.1. Those three lines took the whole node down.
Here's what I dug up, roughly in the order I dug it up.
How real IPs actually travel
nginx has exactly two ways to hand a client's real IP to a backend.
Through HTTP headers. X-Forwarded-For and X-Real-IP, paired with the real_ip module. nginx reads the header and rewrites $remote_addr.
Catch: real_ip is an http module. Layer 7 only. Run VLESS as raw TCP — the most common setup — and nginx forwards it through the stream module, where real_ip doesn't exist at all. Configuring it there does literally nothing.
Through PROXY protocol. That's the layer-4 answer. Before anything else, nginx sends one line toward sing-box:
PROXY TCP4 8.8.8.8 1.2.3.4 54321 443\r\n
The raw TCP stream follows right behind it. sing-box reads that line and knows who the real client is.
Which is where my night went sideways. PROXY protocol needs both sides to opt in. Turn it on at one end only and things break loudly.
Pitfall 1: nginx sends PROXY headers, sing-box isn't listening
Your stream config looks like this:
stream {
server {
listen 443;
proxy_pass 127.0.0.1:8443;
proxy_protocol on; # sends PROXY header to sing-box
}
}
nginx sends the header happily. sing-box never opted in, so the first bytes it sees aren't a VLESS version byte — they're this:
PROXY TCP4 8.8.8.8 1.2.3.4 54321 443
sing-box tries to parse that line as a VLESS header. UUID doesn't match, version doesn't match, so it throws parse VLESS header failed and hangs up.
Symptoms: handshakes die instantly, logs full of parse errors. Nobody suspects nginx first, because nginx -t passes and reload succeeds. nginx did forward the traffic "correctly" — it just poisoned the first line of it.
Fix: tell sing-box to accept the header. Older versions took it on the inbound:
{
"type": "vless",
"listen": "127.0.0.1",
"listen_port": 8443,
"accept_proxy_protocol": true,
"users": []
}
Version trap: sing-box 1.13 dropped that field from the JSON schema (the source marks it Deprecated/removed). Copy a tutorial from two years ago and config validation greets you with unknown field. When a node dies right after a sing-box upgrade and nobody touched the config, this is usually why.
Pitfall 2: the mirror image — sing-box waits, nginx never sends
Same trap, flipped. sing-box has accept_proxy_protocol: true and sits there waiting for the PROXY line. nginx never got proxy_protocol on, so it just sends the raw stream.
Nothing arrives, the connection hangs. Clients see timeouts instead of instant failures, and that's your tell. Instant disconnect: nginx sent a header it shouldn't have. Timeout: nginx skipped one it should have sent.
Pitfall 3: real_ip trusts the entire world
HTTP-based setups (VLESS+WS) invite this one:
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
0.0.0.0/0 means "trust the X-Forwarded-For header from anyone". You've just announced to the internet that forged XFF values count as truth.
Now your logs show "real IPs" like 8.8.8.8 from clients that never completed a TCP handshake.
It gets worse if sing-box does anything with source IPs — fallback to a decoy site, allowlists, rate limiting. Every one of those rules either gets bypassed or fires at the wrong person.
List only the proxy layer directly in front of you:
# assuming one internal reverse proxy in front
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
real_ip_recursive on deserves its own paragraph. Leave it at the default off and nginx takes the rightmost address in XFF. Behind multiple proxies, that's the second-to-last hop, not your client. Switch it on and nginx walks right to left, picking the first address not in the trusted list. That one's the real client.
Pitfall 4: configuring real_ip for raw TCP is pure theater
No WS, no gRPC, just plain TCP — especially with xtls-rprx-vision flow control. That path runs through the stream module, and the real_ip module isn't there.
Try it anyway:
stream {
server {
listen 443;
proxy_pass 127.0.0.1:8443;
set_real_ip_from 0.0.0.0/0; # ❌ no such directive in stream, config error
}
}
Instant config error. set_real_ip_from isn't a stream directive. For raw TCP, PROXY protocol is the only way to get real client IPs. There's no plan B.
Working config templates
Raw VLESS (TCP): nginx stream + PROXY protocol, both sides opted in.
stream {
server {
listen 443;
proxy_pass 127.0.0.1:8443;
proxy_protocol on;
}
}
// sing-box (match your installed version's schema)
{
"type": "vless",
"listen": "127.0.0.1",
"listen_port": 8443,
"accept_proxy_protocol": true,
"users": [
{ "uuid": "your-uuid", "flow": "xtls-rprx-vision" }
]
}
VLESS + WebSocket: HTTP layer, real_ip module, trust range locked down.
server {
listen 443 ssl;
server_name your.domain.com;
set_real_ip_from 127.0.0.1; # trust only your own proxy layer
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location /vless {
proxy_pass http://127.0.0.1:8443;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
How to verify the fix
- Watch sing-box logs:
journalctl -u sing-box -f. A healthy connection logs something likeinbound/vless-in: connection from 8.8.8.8:54321— the real source, not 127.0.0.1. - Capture the PROXY line:
tcpdump -i lo -A port 8443 | head. ThePROXY TCP4line should sit right at the start. - Try to spoof yourself:
curl -I -H "X-Forwarded-For: 8.8.8.8" https://your.domain/vless, then read the access log. If remote_addr really turns into 8.8.8.8, your trust range is too wide.
The takeaway
Three rules for putting nginx in front of sing-box:
- Know which layer your traffic uses, http or stream. That decides real_ip vs PROXY protocol, and the two paths never mix.
- PROXY protocol goes on at both ends or neither. One-sided, you get instant disconnects or timeouts.
set_real_ip_fromlists trusted networks only.0.0.0.0/0hands your IP trust to anyone who can send an HTTP request.
Someone will say VLESS traffic is encrypted anyway, so who cares whether the logged IP is fake. Here's who: fallback decoys, IP-based rate limiting, abuse protection, forensics after an attack. All of it leans on that one address. Fake the address and those defenses are made of paper.
My rule since that night: anything touching proxy-protocol handoffs gets a packet capture before it goes live. Three config lines killed a working node once. Once is enough.
Go pull up your own nginx config right now. If set_real_ip_from 0.0.0.0/0 is sitting in there, or proxy_protocol is on at exactly one end, you already know how that story ends.
References
- nginx ngx_http_realip_module docs: http://nginx.org/en/docs/http/ngx_http_realip_module.html
- sing-box VLESS inbound docs: https://sing-box.sagernet.org/configuration/inbound/vless/
- sing-box Listen Fields: https://sing-box.sagernet.org/configuration/shared/listen/
- sing-box migration notes (proxy_protocol field removal): https://sing-box.sagernet.org/migration/
✨ Drafted by DeepSeek, edited by Claude.