Skip to main content

Preserve the client source IP

Inspect 1.36

Your pods need the real client IP. Rate limiting, audit logs, WAF rules, and a NetworkPolicy that matches on source IP all decide by the caller's address, and they work only if the pod sees the client rather than the load balancer. By default it does not: every request arrives with the Hetzner load balancer as its source. Enable PROXY protocol to recover the real address.

Why the address is lost#

A request takes several hops to reach your pod:

flowchart LR
  C["Client"] --> LB["Hetzner load balancer"] --> N["Node NodePort"] --> P["Pod"]

The load balancer terminates the client's connection and opens a new one to a node. On that new connection the source is the load balancer, so the client's address is already gone by the time the packet reaches the pod. Inside the cluster the node can replace it again. With the default externalTrafficPolicy, a node that forwards the request to a pod on another node source-NATs it, so the pod sees the node's address.

Two ways to recover it#

PROXY protocol#

PROXY protocol is the reliable solution, and on a Hetzner load balancer it is the only one, because the load balancer always replaces the source on the connection it opens.

As the load balancer forwards the connection, it prepends a small header of plain bytes at the very start of the stream, before any TLS or HTTP data. That header carries the original client IP and port. The load balancer writes it as it reads and forwards the packets. Because the header sits at the TCP level, ahead of the payload, it works even when TLS passes straight through to your pod. The backend must be configured to expect the header, or it reads those bytes as invalid input and the connection breaks.

It works for TCP and HTTP, IPv4 and IPv6. The uses-proxyprotocol annotation that turns it on is listed with the rest in . Wire both sides in .

externalTrafficPolicy: Local#

Setting externalTrafficPolicy: Local on the Service stops the node from forwarding to a pod on another node, so it removes the source-NAT hop inside the cluster:

yaml
		spec:
  externalTrafficPolicy: Local
	

Local by itself still does not provide the real client address on Hetzner, because the load balancer already replaced it before the packet reached the node. Use Local to avoid the extra in-cluster hop and to keep the load balancer's health check tied to a local ready pod. Use PROXY protocol when you need the caller's address.

Local has a cost. The load balancer delivers only to nodes that run a ready pod for the Service. If a node has no pod, it drops out of the target pool. Spread the workload across nodes and keep a PodDisruptionBudget; otherwise a drain can leave a node with no local pod and no traffic. The health check that governs this churn is tuned in .

Note

Direct server return and BGP-based source preservation are not available on Hetzner load balancers. PROXY protocol is the mechanism the platform supports.

Verify what the pod sees#

For an HTTP application behind ingress-nginx with PROXY protocol enabled, the controller writes the real client address into the X-Forwarded-For header. Echo it back from a test application, or read the ingress controller's access logs, and confirm the address is the client's rather than the load balancer's.

Where to go next#

Recovering the client address is the foundation for the rest of the controls that decide by address.