Skip to main content

Handle hairpinning to a Service's own LB IP

Inspect 1.36

Hairpinning (also known as NAT loopback) occurs when an in-cluster pod connects to a Kubernetes Service using its public Load Balancer IP address or domain name rather than its internal ClusterIP. Common examples include an ingress controller health-checking itself through its public domain, or microservices calling shared endpoints via external URLs.

On many Kubernetes platforms, hairpin connections fail or time out because internal packet routing conflicts with external proxy configurations. On Syself Autopilot, hairpinning works out of the box because the Hetzner Cloud Controller Manager (CCM) automatically sets the Service's ipMode based on your configuration.

flowchart LR
  P["Client Pod"] -->|"1. Connects to external LB IP"| LB["Hetzner Load Balancer"]
  LB -->|"2. Routes connection back to cluster"| B["Target Pod"]

How ipMode determines the traffic path

The CCM writes status.loadBalancer.ingress[*].ipMode on the Service, and its value determines how hairpin traffic is routed.

  • Standard mode (ipMode: VIP): When is not enabled, the CCM assigns ipMode: VIP. Cilium eBPF intercepts traffic to the load balancer IP directly in the Linux kernel on the node and routes it straight to a backend pod. The external Hetzner Load Balancer is bypassed completely, minimizing latency.
  • Proxy mode (ipMode: Proxy): When PROXY protocol is enabled (uses-proxyprotocol: "true"), the CCM automatically configures ipMode: Proxy. Cilium does not short-circuit the connection locally; instead, traffic leaves the node and reaches the external Hetzner Load Balancer, which injects the required PROXY protocol header before routing the request back into the cluster.

The CCM manages ipMode automatically based on the uses-proxyprotocol service annotation .

When hairpinning fails

Hairpin failures typically stem from configuration mismatch where your backend application (such as an ingress controller) expects PROXY protocol headers, but the Service's ipMode is set to VIP instead of Proxy.

In this scenario, Cilium intercepts the in-cluster traffic locally and routes it straight to the backend pod without passing through the Hetzner Load Balancer. Because the connection never touches the load balancer, no PROXY header is attached. The backend pod receives raw request bytes where it expected a PROXY header, treats the input as invalid protocol data, and immediately drops or rejects the connection (often causing timeouts or 400 Bad Request errors).

Note

On standard Syself Autopilot clusters, the CCM automatically sets ipMode: Proxy whenever uses-proxyprotocol: "true" is set. This failure only occurs if ipMode is overridden or managed by an outdated controller.

Verify it

Once the CCM has processed the Service, read the field:

		$ kubectl get svc <service-name> -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, .