By default, your pods see the Hetzner load balancer's address instead of the real client's. Rate limiting, Web Application Firewalls (WAF), audit logs and `NetworkPolicy` IP rules then treat every request as coming from one client. This page explains why the address is lost, how to recover it with PROXY protocol, what `externalTrafficPolicy: Local` changes, and how calls from inside the cluster to the load balancer IP (hairpinning) behave.
## Why the address is lost
A request takes several hops to reach your pod:
```mermaid
flowchart LR
C(["Client
Src: 203.0.113.5"]):::you --> LB["Hetzner LB
Src: 162.55.158.16"]:::external
LB -- "1. External SNAT" --> N1["Node A (NodePort)
No local pod"]:::platform
N1 -- "2. Inter-node SNAT" --> P["Pod on Node B
Sees Node A IP"]:::app
```
The client IP is replaced twice:
1. **At the load balancer:** the Hetzner load balancer opens a new TCP connection to a node and puts its own address on it (source NAT, SNAT).
2. **Inside the cluster:** with the default `externalTrafficPolicy: Cluster`, a node without a matching pod forwards the request to another node and replaces the source again, this time with the **forwarding node's Cilium address from the pod CIDR**.
## Recover the client IP with PROXY protocol
PROXY protocol is the only way to recover the client IP, because the Hetzner load balancer always replaces the source address. With PROXY protocol on, the load balancer adds a small header with the client's IP and port at the start of every connection, before any TLS or HTTP data. That works even when TLS passes straight through to the pod.
Set it on **both** sides: the load balancer adds the header, and the backend reads it. If the load balancer sends the header and the backend does not read it, every connection breaks.
Enable it at the load balancer
Add the annotation to the `type: LoadBalancer` Service:
```yaml
metadata:
annotations:
load-balancer.hetzner.cloud/uses-proxyprotocol: "true"
```
> [!WARNING]
> Deleting the annotation does **not** turn PROXY protocol off. Set it to `"false"` instead. Otherwise the load balancer keeps sending PROXY headers, and connections fail once your backend stops reading them.
Tell the backend to read it
The backend side depends on what receives the connection.
Enable `proxyProtocol` on every entry point the load balancer targets (`web` and `websecure`). Traefik only trusts PROXY headers from IPs you list. It ignores headers from any other source, and those requests lose the client IP. A connection reaches Traefik either from the load balancer or forwarded by another node, so list both:
```yaml title="traefik-values.yaml"
ports:
web:
proxyProtocol:
trustedIPs:
- /32
-
websecure:
proxyProtocol:
trustedIPs:
- /32
-
```
An application that reads the connection directly must parse the PROXY protocol header itself, before it reads the request bytes. Use a PROXY protocol library for your language rather than parsing the header manually.
> [!IMPORTANT]
> Turn on PROXY protocol before you rely on rate limits, a WAF or source-IP network policies. Until both sides are set, they see every request as coming from the load balancer.
## Eliminate the in-cluster hop with `externalTrafficPolicy: Local`
With `externalTrafficPolicy: Local`, the load balancer only sends traffic to nodes that run a ready pod for the Service, so no node forwards it on and the second replacement goes away:
```yaml
spec:
externalTrafficPolicy: Local
```
`Local` alone does not restore the client IP, because the load balancer has already replaced it. Use PROXY protocol when your workloads need the client's IP.
Nodes without a ready pod drop out of the load balancer's targets. To avoid dropped connections during node replacements, spread replicas across nodes with `topologySpreadConstraints` and set a [PodDisruptionBudget](/docs/hetzner/apalla/workloads/production/pod-disruption-budgets). See [Tune health checks for node replacement](/docs/hetzner/apalla/network/load-balancing/health-checks-and-node-replacement).
> [!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 an ingress controller 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.
## Handle hairpinning to a Service's own LB IP
**Hairpinning** (also called NAT loopback) is when a pod calls a Service through its public load balancer IP or domain instead of its `ClusterIP`, for example an ingress controller checking itself through its public domain. On many platforms these calls fail or time out. On Syself Autopilot they work out of the box, because the Hetzner Cloud Controller Manager (CCM) sets the Service's `ipMode` for you.
```mermaid
flowchart LR
P["Client Pod"]:::app -->|"1. Connects to external LB IP"| LB["Hetzner Load Balancer"]:::external
LB -->|"2. Routes connection back to cluster"| B["Target Pod"]:::app
```
### How `ipMode` determines the traffic path
The CCM writes `status.loadBalancer.ingress[*].ipMode` on the Service, and its value decides the path:
- **`ipMode: VIP` (PROXY protocol off):** Cilium catches the call on the node and sends it straight to a backend pod. The load balancer is skipped.
- **`ipMode: Proxy` (PROXY protocol on):** the call leaves the node and goes through the load balancer, which adds the PROXY header before sending it back into the cluster.
### When hairpinning fails
It fails only when the backend requires a PROXY header but the Service's `ipMode` is `VIP`. Cilium then sends the call straight to the pod without a PROXY header, and the backend rejects it, often with a timeout or `400 Bad Request`. The CCM sets `Proxy` whenever `uses-proxyprotocol` is `"true"`, so this only happens if `ipMode` is overridden or set by an outdated controller.
### Verify the `ipMode`
Once the CCM has processed the Service, read the field:
```console
$ kubectl get svc -o jsonpath='{.status.loadBalancer.ingress[0].ipMode}'
Proxy
```
With PROXY protocol enabled, expect `Proxy`. Without it, expect `VIP`. If you set `load-balancer.hetzner.cloud/hostname` on the Service, the ingress entry carries only the hostname and this command returns nothing. If a hairpin call still times out, see [Debug node networking](/docs/hetzner/apalla/network/debug/debug-node-networking).
## Where to go next
- [Segment with network policies](/docs/hetzner/apalla/security/segment-with-network-policies)
Recovering the client address is the foundation for the rest of the [Security](/docs/hetzner/apalla/concepts/ownership/security-model) controls that decide by address.