[{"data":1,"prerenderedAt":747},["ShallowReactive",2],{"blog-nginx-proxy-protocol":3},{"id":4,"title":5,"body":6,"date":734,"description":735,"extension":736,"image":16,"meta":737,"navigation":233,"path":738,"seo":739,"stem":740,"tags":741,"__hash__":746},"blog\u002Fblog\u002Fnginx-proxy-protocol.md","Real client IPs through the stream hop, with PROXY protocol",{"type":7,"value":8,"toc":724},"minimark",[9,17,31,34,81,86,89,96,100,103,111,118,129,133,194,205,209,212,301,304,364,411,431,438,442,452,485,517,524,564,568,575,594,603,607,610,654,673,677,704,707,720],[10,11,12],"p",{},[13,14],"img",{"alt":15,"src":16},"PROXY protocol across the stream hop","\u002Fimages\u002Fblog\u002Fbanner-proxy-protocol-light.png",[10,18,19,20,25,26,30],{},"At the end of ",[21,22,24],"a",{"href":23},"\u002Fblog\u002Fnginx-stream-unix-sockets","the unix socket swap"," I left a loose end: my analytics for customer domains had quietly turned into one visitor. The ",[27,28,29],"code",{},"stream"," block forwards raw TCP, so nothing downstream knows who connected — and once the hops moved to sockets, downstream doesn't even get an address. The peer on a unix socket is just \"unix\".",[10,32,33],{},"This post is the fix: PROXY protocol across the same sockets, and what it took to get Caddy and Nginx to speak it.",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-mermaid shiki shiki-themes github-light github-dark","flowchart LR\n    V[\"Visitor\u003Cbr>203.0.113.50\"] -->|HTTPS :443| N[\"Nginx stream (L4)\"]\n    N -->|\"unix socket + PROXY line\"| H[\"Nginx http · ssl\"]\n    N -->|\"SNI: custom domain · socket + PROXY line\"| C[\"Caddy on-demand TLS\"]\n    H -->|\"$remote_addr = visitor\"| App[\"App\"]\n    C -->|\"{remote_host} = visitor\"| App\n","mermaid","",[27,42,43,51,57,63,69,75],{"__ignoreMap":40},[44,45,48],"span",{"class":46,"line":47},"line",1,[44,49,50],{},"flowchart LR\n",[44,52,54],{"class":46,"line":53},2,[44,55,56],{},"    V[\"Visitor\u003Cbr>203.0.113.50\"] -->|HTTPS :443| N[\"Nginx stream (L4)\"]\n",[44,58,60],{"class":46,"line":59},3,[44,61,62],{},"    N -->|\"unix socket + PROXY line\"| H[\"Nginx http · ssl\"]\n",[44,64,66],{"class":46,"line":65},4,[44,67,68],{},"    N -->|\"SNI: custom domain · socket + PROXY line\"| C[\"Caddy on-demand TLS\"]\n",[44,70,72],{"class":46,"line":71},5,[44,73,74],{},"    H -->|\"$remote_addr = visitor\"| App[\"App\"]\n",[44,76,78],{"class":46,"line":77},6,[44,79,80],{},"    C -->|\"{remote_host} = visitor\"| App\n",[82,83,85],"h2",{"id":84},"why-the-address-disappears","Why the address disappears",[10,87,88],{},"The stream server terminates nothing. It peeks at the SNI, picks a backend, and copies bytes from one file descriptor to another. It never parses HTTP, so it can't add headers — there's no HTTP to attach anything to. And it can't decrypt to find out either; that's the whole point of the hop.",[10,90,91,92,95],{},"So Caddy's idea of the remote address is Nginx's connection, and the app's idea is Caddy. Rate limiting, abuse blocking, logs, analytics: they all see one client. The socket swap made it starker — over loopback TCP at least everyone agreed on ",[27,93,94],{},"127.0.0.1","; over a unix socket the peer address is a path that tells you nothing.",[82,97,99],{"id":98},"what-proxy-protocol-is","What PROXY protocol is",[10,101,102],{},"A short plaintext line that the stream block prepends to the connection, before the TLS handshake:",[35,104,109],{"className":105,"code":107,"language":108},[106],"language-text","PROXY TCP4 203.0.113.50 10.0.0.5 51234 443\n","text",[27,110,107],{"__ignoreMap":40},[10,112,113,114,117],{},"The backend reads the line, strips it, and treats ",[27,115,116],{},"203.0.113.50"," as the connection's remote address. TLS then proceeds untouched — the ClientHello is byte-for-byte what the visitor sent.",[10,119,120,121,124,125,128],{},"One thing to be clear about: the header is a claim, not proof. Whoever can connect to a PROXY-protocol listener can send ",[27,122,123],{},"PROXY TCP4 1.2.3.4 ..."," and impersonate any IP, which quietly defeats rate limits and bans. This is where the ",[27,126,127],{},"0750"," socket directory from the last post stops being a nice-to-have: only Caddy and the Nginx group can reach these listeners at all. On loopback TCP, every process on the box could.",[82,130,132],{"id":131},"sending-it-from-stream","Sending it from stream",[35,134,138],{"className":135,"code":136,"language":137,"meta":40,"style":40},"language-nginx shiki shiki-themes github-light github-dark","server {\n    listen 443;\n    ssl_preread on;\n    proxy_pass $https_backend;\n    proxy_protocol on;\n}\n","nginx",[27,139,140,150,162,172,180,189],{"__ignoreMap":40},[44,141,142,146],{"class":46,"line":47},[44,143,145],{"class":144},"szBVR","server",[44,147,149],{"class":148},"sVt8B"," {\n",[44,151,152,155,159],{"class":46,"line":53},[44,153,154],{"class":144},"    listen ",[44,156,158],{"class":157},"sj4cs","443",[44,160,161],{"class":148},";\n",[44,163,164,167,170],{"class":46,"line":59},[44,165,166],{"class":144},"    ssl_preread ",[44,168,169],{"class":157},"on",[44,171,161],{"class":148},[44,173,174,177],{"class":46,"line":65},[44,175,176],{"class":144},"    proxy_pass ",[44,178,179],{"class":148},"$https_backend;\n",[44,181,182,185,187],{"class":46,"line":71},[44,183,184],{"class":144},"    proxy_protocol ",[44,186,169],{"class":157},[44,188,161],{"class":148},[44,190,191],{"class":46,"line":77},[44,192,193],{"class":148},"}\n",[10,195,196,197,200,201],{},"The trap is that this is all-or-nothing. ",[27,198,199],{},"proxy_protocol on"," prefixes every connection this server forwards, including the ones headed for the main app. ",[202,203,204],"strong",{},"Every backend in the map must accept PROXY protocol, or none of them can.",[82,206,208],{"id":207},"the-caddy-side","The Caddy side",[10,210,211],{},"Caddy speaks PROXY protocol through a listener wrapper:",[35,213,217],{"className":214,"code":215,"language":216,"meta":40,"style":40},"language-caddy shiki shiki-themes github-light github-dark","{\n    http_port 8082\n\n    on_demand_tls {\n        ask http:\u002F\u002F127.0.0.1:8000\u002Finternal\u002Fdomain-check\n    }\n\n    servers unix\u002F\u002Frun\u002Fapp-sockets\u002Fcaddy-tls.sock|0666 {\n        protocols h1 h2\n        listener_wrappers {\n            proxy_protocol\n            tls\n        }\n    }\n}\n","caddy",[27,218,219,224,229,235,240,245,250,255,261,267,273,279,285,291,296],{"__ignoreMap":40},[44,220,221],{"class":46,"line":47},[44,222,223],{},"{\n",[44,225,226],{"class":46,"line":53},[44,227,228],{},"    http_port 8082\n",[44,230,231],{"class":46,"line":59},[44,232,234],{"emptyLinePlaceholder":233},true,"\n",[44,236,237],{"class":46,"line":65},[44,238,239],{},"    on_demand_tls {\n",[44,241,242],{"class":46,"line":71},[44,243,244],{},"        ask http:\u002F\u002F127.0.0.1:8000\u002Finternal\u002Fdomain-check\n",[44,246,247],{"class":46,"line":77},[44,248,249],{},"    }\n",[44,251,253],{"class":46,"line":252},7,[44,254,234],{"emptyLinePlaceholder":233},[44,256,258],{"class":46,"line":257},8,[44,259,260],{},"    servers unix\u002F\u002Frun\u002Fapp-sockets\u002Fcaddy-tls.sock|0666 {\n",[44,262,264],{"class":46,"line":263},9,[44,265,266],{},"        protocols h1 h2\n",[44,268,270],{"class":46,"line":269},10,[44,271,272],{},"        listener_wrappers {\n",[44,274,276],{"class":46,"line":275},11,[44,277,278],{},"            proxy_protocol\n",[44,280,282],{"class":46,"line":281},12,[44,283,284],{},"            tls\n",[44,286,288],{"class":46,"line":287},13,[44,289,290],{},"        }\n",[44,292,294],{"class":46,"line":293},14,[44,295,249],{},[44,297,299],{"class":46,"line":298},15,[44,300,193],{},[10,302,303],{},"Three details, and I hit all three:",[305,306,307,325,343],"ul",{},[308,309,310,317,318,321,322,316],"li",{},[202,311,312,313,316],{},"The directive is ",[27,314,315],{},"listener_wrappers","."," I typed ",[27,319,320],{},"listen_wrappers"," first and got ",[27,323,324],{},"unrecognized servers option",[308,326,327,330,331,334,335,338,339,342],{},[202,328,329],{},"Order matters."," ",[27,332,333],{},"proxy_protocol"," must come before ",[27,336,337],{},"tls",", because the PROXY line arrives before the ClientHello. Reverse them and Caddy tries to parse ",[27,340,341],{},"PROXY TCP4..."," as a TLS handshake.",[308,344,345,348,349,352,353,355,356,359,360,363],{},[202,346,347],{},"Scope it to the socket."," A bare ",[27,350,351],{},"servers { }"," applies to every listener, including plain HTTP on 8082 — and a ",[27,354,337],{}," wrapper on an ACME port breaks HTTP-01 challenges and the redirect. The address after ",[27,357,358],{},"servers"," has to match the listener Caddy actually creates, and a mismatch fails silently: ",[27,361,362],{},"caddy validate"," still passes. Check it with adapt:",[35,365,369],{"className":366,"code":367,"language":368,"meta":40,"style":40},"language-bash shiki shiki-themes github-light github-dark","sudo caddy adapt --config \u002Fetc\u002Fcaddy\u002FCaddyfile --pretty | grep -n -B3 -A10 listener_wrappers\n","bash",[27,370,371],{"__ignoreMap":40},[44,372,373,377,381,384,387,390,393,396,399,402,405,408],{"class":46,"line":47},[44,374,376],{"class":375},"sScJk","sudo",[44,378,380],{"class":379},"sZZnC"," caddy",[44,382,383],{"class":379}," adapt",[44,385,386],{"class":157}," --config",[44,388,389],{"class":379}," \u002Fetc\u002Fcaddy\u002FCaddyfile",[44,391,392],{"class":157}," --pretty",[44,394,395],{"class":144}," |",[44,397,398],{"class":375}," grep",[44,400,401],{"class":157}," -n",[44,403,404],{"class":157}," -B3",[44,406,407],{"class":157}," -A10",[44,409,410],{"class":379}," listener_wrappers\n",[10,412,413,414,416,417,419,420,423,424,427,428,430],{},"You want ",[27,415,333],{}," then ",[27,418,337],{}," inside a server whose ",[27,421,422],{},"listen"," is the socket path. If the block is missing, copy the exact ",[27,425,426],{},"\"listen\""," string from the adapt output into the ",[27,429,358],{}," line.",[10,432,433,434,437],{},"(",[27,435,436],{},"protocols h1 h2"," is there because the socket carries TCP only — there's no UDP on it for HTTP\u002F3.)",[82,439,441],{"id":440},"the-main-app-has-to-accept-it-too","The main app has to accept it too",[10,443,444,445,448,449,451],{},"The other backend in the map is Nginx's own ",[27,446,447],{},"http"," server. Its ",[27,450,422],{}," line gains a parameter, plus two directives to actually use the header:",[35,453,455],{"className":135,"code":454,"language":137,"meta":40,"style":40},"listen unix:\u002Frun\u002Fapp-sockets\u002Fnginx-app.sock ssl proxy_protocol;\n\nset_real_ip_from unix:;\nreal_ip_header   proxy_protocol;\n",[27,456,457,465,469,477],{"__ignoreMap":40},[44,458,459,462],{"class":46,"line":47},[44,460,461],{"class":144},"listen ",[44,463,464],{"class":148},"unix:\u002Frun\u002Fapp-sockets\u002Fnginx-app.sock ssl proxy_protocol;\n",[44,466,467],{"class":46,"line":53},[44,468,234],{"emptyLinePlaceholder":233},[44,470,471,474],{"class":46,"line":59},[44,472,473],{"class":144},"set_real_ip_from ",[44,475,476],{"class":148},"unix:;\n",[44,478,479,482],{"class":46,"line":65},[44,480,481],{"class":144},"real_ip_header ",[44,483,484],{"class":148},"  proxy_protocol;\n",[10,486,487,489,490,492,493,497,498,501,502,505,506,509,510,513,514,316],{},[27,488,333],{}," on the ",[27,491,422],{}," line makes Nginx ",[494,495,496],"em",{},"parse"," the line. ",[27,499,500],{},"set_real_ip_from"," together with ",[27,503,504],{},"real_ip_header proxy_protocol"," makes it ",[494,507,508],{},"use"," the line to set ",[27,511,512],{},"$remote_addr",". You need both — parse alone just strips the header and leaves you back at \"no useful address\". For a unix socket peer, the trusted source is ",[27,515,516],{},"unix:",[10,518,519,520,523],{},"Because one side now sends the header and the other side ",[494,521,522],{},"requires"," it, there's no rolling this out one backend at a time. Switch both sides together:",[35,525,527],{"className":366,"code":526,"language":368,"meta":40,"style":40},"sudo nginx -t && sudo systemctl restart caddy && sudo systemctl reload nginx\n",[27,528,529],{"__ignoreMap":40},[44,530,531,533,536,539,542,544,547,550,552,554,556,558,561],{"class":46,"line":47},[44,532,376],{"class":375},[44,534,535],{"class":379}," nginx",[44,537,538],{"class":157}," -t",[44,540,541],{"class":148}," && ",[44,543,376],{"class":375},[44,545,546],{"class":379}," systemctl",[44,548,549],{"class":379}," restart",[44,551,380],{"class":379},[44,553,541],{"class":148},[44,555,376],{"class":375},[44,557,546],{"class":379},[44,559,560],{"class":379}," reload",[44,562,563],{"class":379}," nginx\n",[82,565,567],{"id":566},"what-this-buys-downstream","What this buys downstream",[10,569,570,571,574],{},"With the wrapper in place, ",[27,572,573],{},"{remote_host}"," in Caddy resolves to the visitor, so anything it proxies can pass the address on:",[35,576,578],{"className":214,"code":577,"language":216,"meta":40,"style":40},"reverse_proxy 127.0.0.1:3000 {\n    header_up X-Real-IP {remote_host}\n}\n",[27,579,580,585,590],{"__ignoreMap":40},[44,581,582],{"class":46,"line":47},[44,583,584],{},"reverse_proxy 127.0.0.1:3000 {\n",[44,586,587],{"class":46,"line":53},[44,588,589],{},"    header_up X-Real-IP {remote_host}\n",[44,591,592],{"class":46,"line":59},[44,593,193],{},[10,595,596,597,599,600,602],{},"And in the Nginx ",[27,598,447],{}," block, ",[27,601,512],{}," is the visitor again — which rate limiting and logs pick up with no further config.",[82,604,606],{"id":605},"how-i-verified-it","How I verified it",[10,608,609],{},"A page that loads proves nothing. I checked each path separately, from a machine other than the server:",[35,611,613],{"className":366,"code":612,"language":368,"meta":40,"style":40},"curl -s ifconfig.me                                # your public IP\ncurl -s https:\u002F\u002Ftimer.customer.com\u002Fcheck-ip        # customer-domain path\ncurl -s https:\u002F\u002Fapp.example.com\u002Fcheck-ip           # main-app path\n",[27,614,615,630,642],{"__ignoreMap":40},[44,616,617,620,623,626],{"class":46,"line":47},[44,618,619],{"class":375},"curl",[44,621,622],{"class":157}," -s",[44,624,625],{"class":379}," ifconfig.me",[44,627,629],{"class":628},"sJ8bj","                                # your public IP\n",[44,631,632,634,636,639],{"class":46,"line":53},[44,633,619],{"class":375},[44,635,622],{"class":157},[44,637,638],{"class":379}," https:\u002F\u002Ftimer.customer.com\u002Fcheck-ip",[44,640,641],{"class":628},"        # customer-domain path\n",[44,643,644,646,648,651],{"class":46,"line":59},[44,645,619],{"class":375},[44,647,622],{"class":157},[44,649,650],{"class":379}," https:\u002F\u002Fapp.example.com\u002Fcheck-ip",[44,652,653],{"class":628},"           # main-app path\n",[10,655,656,657,660,661,664,665,668,669,672],{},"Both ",[27,658,659],{},"\u002Fcheck-ip"," endpoints should print your public IP. (",[27,662,663],{},"ifconfig.me"," may answer over IPv6 while your other path returns IPv4; force a family with ",[27,666,667],{},"-4"," or ",[27,670,671],{},"-6"," if they disagree.)",[82,674,676],{"id":675},"what-id-tell-past-me","What I'd tell past me",[678,679,680,686,701],"ol",{},[308,681,682,683,685],{},"Treat ",[27,684,333],{}," as a change to the whole proxy chain, not one backend. Every backend in the map accepts it, or none can — and both sides switch together.",[308,687,688,689,692,693,696,697,700],{},"After editing ",[27,690,691],{},"servers \u003Caddress>"," in Caddy, run ",[27,694,695],{},"caddy adapt"," and confirm the wrapper attached. ",[27,698,699],{},"validate"," passing doesn't prove it.",[308,702,703],{},"Check real client IPs in the logs on day one, not after a week of bad analytics.",[705,706],"hr",{},[10,708,709,330,712,716,717,719],{},[202,710,711],{},"Read the series from the start:",[21,713,715],{"href":714},"\u002Fblog\u002Fmulti-tenant-custom-domains","Multi-Tenant Custom Domains"," and ",[21,718,24],{"href":23}," set up everything this post leans on.",[721,722,723],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":40,"searchDepth":53,"depth":53,"links":725},[726,727,728,729,730,731,732,733],{"id":84,"depth":53,"text":85},{"id":98,"depth":53,"text":99},{"id":131,"depth":53,"text":132},{"id":207,"depth":53,"text":208},{"id":440,"depth":53,"text":441},{"id":566,"depth":53,"text":567},{"id":605,"depth":53,"text":606},{"id":675,"depth":53,"text":676},"2026-10-26","The stream hop forwards raw TCP, so nothing downstream ever saw the visitor. PROXY protocol fixes that — here's the setup and the traps that come with it.","md",{},"\u002Fblog\u002Fnginx-proxy-protocol",{"title":5,"description":735},"blog\u002Fnginx-proxy-protocol",[742,743,744,745],"Nginx","Caddy","TLS","Infrastructure","3MYUrWmL7s7RwZgLdeI3IJB2Xpvp-DyiIHEMIT2OHEs",1793038263520]