10.3 PROXY Protocol
A TCP proxy is also a lie factory: every upstream sees 127.0.0.1 as the client. The PROXY protocol is the one-line confession that fixes it.
Advanced
What you will learn
The problem: what the backend sees when you proxy at L4, and why it's worse than the L7 equivalent you already fixed with
X-Forwarded-For.The PROXY protocol v1 wire format — one ASCII line, readable with your own eyes.
proxy_protocol on;in NGINX as sender and receiver — both sides of the hop.The proof: the real client address surviving to the backend.
The operational contract: who must opt in, and what breaks if the receiver isn't ready.
1. What the backend sees
At L7 you solved this with a header —
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for — because HTTP gives you a place to put metadata. At L4 there are no headers. NGINX's upstream connection comes from NGINX's own address, so every session your proxy forwards looks like this to the application:
client address seen by backend: 127.0.0.1 (the proxy!)For a database that means every row in
pg_stat_activity has the same client. For an MQTT broker, every publisher is the proxy. For rate limiting, geo rules, audit logs, abuse blocking — blind. You can't fix this with IP spoofing options like
proxy_bind $remote_addr transparent; without root/CAP_NET_ADMIN and routing gymnastics (transparent proxying is the Plus-grade / kernel-grade answer, and those privileges are exactly what it costs).
The industry's portable answer: prepend one line of metadata to the byte stream and let a cooperating receiver consume it before the real protocol starts.
2. The wire format
PROXY protocol v1 (the default) is a single human-readable line sent as the first bytes of the connection:
PROXY TCP4 203.0.113.7 198.51.100.2 54321 9200\r\n
│ │ │ │ │
│ │ │ │ └─ destination port (proxy's listener)
│ │ │ └─────── source port (client's ephemeral port)
│ │ └──────────────────── destination address (proxy)
│ └───────────────────────────────── source address (the REAL client)
└────────────────────────────────────── transport (TCP4/TCP6/UNKNOWN)After that line, the stream is the application protocol, untouched. v2 exists as a binary framing (TLVs, more metadata) that listeners accept automatically — but as a sender NGINX emits v1 only (
proxy_protocol on|off, no version switch) — but v1 remains the maximally-compatible default and the one you can debug with
tcpdump and your eyeballs.
3. NGINX on both sides
As the sender (listening on
:9203):
server {
listen 9203;
proxy_pass 127.0.0.1:9060;
proxy_protocol on;
}
As the receiver — the part people forget exists. When something
else fronts NGINX (a cloud LB like AWS NLB/ELB in proxy-protocol mode, another NGINX), NGINX must be told to expect and consume the line — this is a
listen
parameter, not a stream-only directive:
# in http{} or stream{} — both understand it:
server {
listen 9200 proxy_protocol; # consume the line, then serve
# and now the real client address is RESTORED as $remote_addr:
real_ip_header proxy_protocol; # http{}: also fix $remote_addr for logs/ACLs
}The example backend (
tcp-echo.py) is a cooperating receiver by construction — it checks whether the first line starts with
PROXY and, if so, both consumes it and proves it arrived.
4. What the wire shows
Send one line through the PROXY-enabled hop (real capture):
reply: PROXY-SEEN client=127.0.0.1 backend=b-tcp-echo-1The backend answered with the address from the PROXY line — which is the
client's address, not NGINX's. (On a loopback test both happen to be
127.0.0.1, so the reply also includes the backend name to prove which server spoke; the
client= field parses from the protocol line.)
What the backend's side of the stream actually looked like — the first "line" it read was never the client's text at all:
PROXY TCP4 127.0.0.1 127.0.0.1 47124 9203
proxymeLine one: prepended by NGINX. Line two: the client's actual payload. A receiver that
doesn't understand the protocol would have tried to parse
PROXY TCP4 ... as application data — which is exactly the failure mode to expect (Section 5).
5. The operational contract
The PROXY protocol is opt-in on both ends, and the failure modes are asymmetric:
Misconfiguration | Symptom |
|---|---|
Sender on, receiver not ready | receiver sees
|
Receiver on, sender not speaking it | NGINX waits for a header line that never terminates cleanly; connections hang or drop on first byte. In
|
Both on, but an extra hop in between that strips it | you're back to proxy-address-as-client; check each hop's docs (cloud LBs often call it "proxy protocol v2 enable") |
Rules that keep you out of trouble:
Enable listener-side first (a
listen … proxy_protocollistener auto-detects and accepts both v1 and v2 headers), then turn on senders, hop by hop.Keep it on private segments — the line is trivially spoofable by anyone who can reach the receiver. A spoofed
PROXYline is an IP-address forgery; never expose a proxy-protocol listener to the public internet.At L7 use it with
real_ip_header+set_real_ip_fromso downstream logs and ACLs see the truth (Module 12's log formats will inherit the fixed$remote_addr).Remember the PROXY bytes are part of the stream —
$bytes_receivedon the session includes the line, and content-length-sensitive protocols at L7 must never see it (which is why receiver-side consumption must happen before the app protocol starts).
Try it yourself
Negative experiment: point
:9203'sproxy_passatb-tcp-echo-1without the backend expecting it — the echo server will faithfully echoPROXY TCP4 ...back at you as if it were chat. (It only parses the line because it was written to; the point is to see the line appear in application data.)Wire-level look (if you have capture rights):
tcpdump -A -i lo port 9060while sending through:9203— the PROXY line is visible ASCII in the first packet's payload.
Q&A
Q: Why not just fix this with
X-Forwarded-For
? A: That's an HTTP header — it only exists if someone parses HTTP. At L4 nobody does. The PROXY protocol is the L4-native equivalent: metadata
in-band, before the payload, no protocol parsing needed.
Q: Does NGINX append the original line when there are chained
proxies? A: No — each hop writes its own line describing
its client. Chains work when every hop both consumes and re-emits (NGINX does exactly that:
$proxy_protocol_addr holds what it received,
proxy_protocol on describes what it forwards).
Q: v1 or v2? A: v1 for compatibility and debuggability (the example above), v2 when you need TLV extensions (SSL client cert info, AWS-specific data) or when the other end is a v2-only appliance. There is no version switch:
listen … proxy_protocol accepts both v1 and v2 automatically — the receiver detects the version from the signature of the first bytes.
Takeaways
At L4 the backend's view of the client is the proxy — PROXY protocol v1 fixes it with one prepended ASCII line on the stream.
NGINX sends it with
proxy_protocol on;and receives it as alisten … proxy_protocolparameter — the receiver side is where misconfiguration bites.Both ends must opt in; mismatch symptoms are "protocol garbage upstream" (sender-only) or "hang on first byte" (receiver-only).
The line is spoofable — keep proxy-protocol listeners on trusted segments, and pair receiver mode with
real_ip_headerat L7 so logs and ACLs inherit the true address.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.