Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Advanced ~25 min read

Advanced

What you will learn

  1. 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.

  2. The PROXY protocol v1 wire format — one ASCII line, readable with your own eyes.

  3. proxy_protocol on; in NGINX as sender and receiver — both sides of the hop.

  4. The proof: the real client address surviving to the backend.

  5. 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:

text
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:

text
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):

nginx
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:

nginx
# 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):

text
reply: PROXY-SEEN client=127.0.0.1 backend=b-tcp-echo-1

The 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:

text
PROXY TCP4 127.0.0.1 127.0.0.1 47124 9203
proxyme

Line 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 PROXY TCP4 ... as protocol garbage — protocol errors, auth failures, or an immediate close. In http{}: 400 Bad Request with PROXY in the body.

Receiver on, sender not speaking it

NGINX waits for a header line that never terminates cleanly; connections hang or drop on first byte. In http{}: 400 with "PROXY protocol header is invalid".

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:

  1. Enable listener-side first (a listen … proxy_protocol listener auto-detects and accepts both v1 and v2 headers), then turn on senders, hop by hop.

  2. Keep it on private segments — the line is trivially spoofable by anyone who can reach the receiver. A spoofed PROXY line is an IP-address forgery; never expose a proxy-protocol listener to the public internet.

  3. At L7 use it with real_ip_header + set_real_ip_from so downstream logs and ACLs see the truth (Module 12's log formats will inherit the fixed $remote_addr).

  4. Remember the PROXY bytes are part of the stream — $bytes_received on 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's proxy_pass at b-tcp-echo-1 without the backend expecting it — the echo server will faithfully echo PROXY 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 9060 while 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 a listen … proxy_protocol parameter — 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_header at L7 so logs and ACLs inherit the true address.

Was this page helpful?

Mark this learning resource complete to track your learning progress.