Deploying behind a TCP load balancer
Recover the real client IP when gatekeeper runs behind a TCP-terminating load balancer, such as GCP’s global TCP Proxy load balancer. Without this, every request’s client_ip log attribute shows the load balancer’s hop instead of the actual client.
PROXY protocol support is configured per listener: proxy.proxy_protocol for the HTTP/CONNECT listener, and postgres.proxy_protocol for the Postgres data-plane listener (if configured). They are independent toggles — enabling one does not enable the other — because the two listeners are commonly fronted by different load balancers, or only one of them is exposed publicly at all. Everything below applies identically to both; the config examples call out where the field name differs. (The one exception: when proxy.port and postgres.port are set to the same value, the two listeners collapse into one shared listener behind a single load balancer, and the two proxy_protocol settings must then agree — see Single load balancer, one shared port below.)
Prerequisites
- A working gatekeeper deployment (see Config file)
- A TCP-terminating load balancer in front of the listener you’re enabling this for, supporting PROXY protocol v1/v2 (for example, GCP’s global TCP Proxy load balancer)
gcloudconfigured against the target GCP project, if following the GCP walkthrough below
Why client_ip shows the load balancer
A TCP-terminating load balancer ends the client’s TCP connection at its own edge and opens a new connection to gatekeeper from its own front-end IP range. GCP’s global TCP Proxy load balancer dials backends from 35.191.0.0/16. By default, gatekeeper reads client_ip from the raw TCP peer address of the accepted connection — so every request logs the load balancer’s address, never the real client, no matter which host actually opened the connection.
How it works
- The client connects to the load balancer’s public IP.
- The load balancer terminates that connection and opens a new TCP connection to gatekeeper, prepending a PROXY protocol v1 or v2 header that names the original client address.
- When
proxy.proxy_protocol: true(HTTP/CONNECT listener) orpostgres.proxy_protocol: true(Postgres listener) is set, gatekeeper wraps that listener with PROXY protocol parsing. On each accepted connection, it reads the leading header (if present) and substitutes the header’s advertised source address for the raw TCP peer address. Both listeners share the exact same parsing logic — fail-open policy, timeout, and malformed-header handling are identical. - That substituted address flows through to the
client_iprequest-log attribute on every request path — including CONNECT-intercepted TLS traffic on the HTTP listener, and the canonical log line and the run-token-authentication-failure log line on the Postgres listener. On the Postgres listener, the PROXY header is always the very first bytes on the wire, so it’s parsed before the client’sSSLRequestand the TLS handshake that follows it. - A connection that opens without a PROXY header — or that doesn’t send one within a 10-second read timeout — falls back to the raw TCP peer address instead of being rejected. This fail-open behavior keeps load balancer health checks and direct probes of the port working.
Enabling proxy_protocol
Set proxy.proxy_protocol: true in gatekeeper.yaml for the HTTP/CONNECT listener:
proxy:
host: 0.0.0.0
port: 9080
proxy_protocol: true
tls:
ca_cert: ca.crt
ca_key: ca.key
credentials:
- host: api.github.com
header: Authorization
grant: github
source:
type: env
var: GITHUB_TOKEN
network:
policy: permissive
log:
level: info
format: text
proxy.host is bound to 0.0.0.0 here because the listener must be reachable from the load balancer, which is not running on the same host. See Security Requirement below before deploying this configuration.
If gatekeeper’s Postgres data-plane listener is also fronted by a TCP-terminating load balancer, enable it there independently with postgres.proxy_protocol: true:
postgres:
host: 0.0.0.0
port: 5432
proxy_protocol: true
Single load balancer, one shared port
The two listeners above don’t have to be separate ports. If proxy.port and postgres.port are set to the same value (and the same host), gatekeeper multiplexes both planes onto one real listener instead of binding two — useful when you only want to stand up one backend service, one health check, and one firewall rule in front of gatekeeper, rather than one per listener.
There’s no separate flag for this: the port equality itself is the declaration. Each accepted connection is classified by its first bytes — a Postgres startup signature (SSLRequest, GSSENCRequest, or the v3 StartupMessage) routes to the Postgres data plane, and everything else (HTTP methods, the h2 preface) routes to the HTTP/CONNECT plane — before being handed to the same HTTP server or Postgres server code that runs the ordinary, separate-port configuration. Classification happens per-connection, not in the shared accept loop, so a silent or slow-to-classify client (an LB health check that opens a socket and never sends anything, for example) can’t stall every other pending connection.
proxy:
host: 0.0.0.0
port: 5432
proxy_protocol: true
tls:
ca_cert: ca.crt
ca_key: ca.key
postgres:
port: 5432
proxy_protocol: true
A shared listener has exactly one PROXY protocol setting, since there’s only one physical socket to wrap: proxy.proxy_protocol and postgres.proxy_protocol must agree (both true or both false, or simply leave postgres.proxy_protocol unset — see the config reference for the exact default). Gatekeeper refuses to start otherwise, with an error naming the mismatch — this is checked at startup, before either listener binds, so a typo doesn’t surface as a mysterious connection-classification bug later. proxy.proxy_protocol is the setting that’s actually applied to the shared listener; postgres.proxy_protocol exists in this mode purely so the two fields can’t silently drift apart in a config that’s meant to describe one listener.
Two configuration mistakes are also rejected at startup for the same reason:
- Equal ports, different hosts — e.g.
proxy.host: 127.0.0.1withpostgres.host: 0.0.0.0and the same port. There’s only one socket to bind, and gatekeeper can’t guess which address you meant. - Equal ports, mismatched
proxy_protocol— described above.
If postgres isn’t configured at all, or the two ports differ, gatekeeper binds two listeners exactly as it always has — the shared-port path only ever engages when you’ve explicitly pointed both configs at the same port and host.
Configuring the GCP backend service
Enabling proxy_protocol on gatekeeper is only half the change — the load balancer must also be told to send the header. For a global TCP Proxy load balancer, this is the backend service’s proxyHeader field. Configure this on whichever backend service fronts the listener you enabled — the HTTP/CONNECT port, the Postgres port, or both, if both are load-balanced. On the shared-port setup above, there’s only one backend service and one health check to configure, since there’s only one listener.
Update an existing backend service:
gcloud compute backend-services update gatekeeper-backend \
--global \
--proxy-header=PROXY_V1
Or set it at creation time:
gcloud compute health-checks create http gatekeeper-health-check \
--port=9080 \
--request-path=/healthz
gcloud compute backend-services create gatekeeper-backend \
--global \
--protocol=TCP \
--health-checks=gatekeeper-health-check \
--proxy-header=PROXY_V1
gcloud compute backend-services add-backend gatekeeper-backend \
--global \
--instance-group=gatekeeper-ig \
--instance-group-zone=us-central1-a
--proxy-header accepts NONE (default) or PROXY_V1. Gatekeeper’s PROXY protocol parser handles both v1 (text) and v2 (binary) headers, but GCP’s global TCP Proxy load balancer sends v1.
The Postgres listener’s backend service follows the same pattern, just pointed at the Postgres port (5432 by default) with a TCP health check instead of an HTTP one against /healthz — the Postgres listener has no HTTP health endpoint; a bare TCP connect check is sufficient, since the listener always answers the Postgres SSLRequest preamble.
Safe rollout ordering
Deploy in this order to avoid an outage, for each listener you’re enabling:
- Enable
proxy_protocol: true(proxy.proxy_protocolorpostgres.proxy_protocol) on gatekeeper first, and deploy it. Because the listener fails open, connections without a PROXY header — which is every connection at this point, since the load balancer isn’t sending one yet — fall back to the raw TCP peer address exactly as before./healthz(HTTP listener) or a plain TCP connect (Postgres listener) stays green throughout. - Confirm gatekeeper is healthy and serving traffic with
proxy_protocolenabled but no header arriving yet. - Update the backend service’s
proxyHeadertoPROXY_V1. Once the load balancer starts sending headers, gatekeeper parses them andclient_ipstarts reflecting real clients.
Reversing this order — turning on proxyHeader: PROXY_V1 before gatekeeper understands it — sends a raw PROXY header into a listener that doesn’t parse it. On the HTTP listener, gatekeeper would treat the header bytes as the start of an HTTP request and reject the connection; on the Postgres listener, it would corrupt the SSLRequest preamble and the connection would be dropped. Either way, gatekeeper goes first.
If both listeners are load-balanced, roll them out independently, one at a time — enabling proxy.proxy_protocol has no effect on the Postgres listener’s behavior, and vice versa.
Security requirement
Warning:
proxy_protocoltrusts the PROXY header from any peer that can reach the listener. A client that connects directly to gatekeeper’s port — bypassing the load balancer — can prepend its own PROXY header and forge its loggedclient_ip.
Only enable proxy_protocol on a listener when its port is reachable solely through the load balancer, and never use client_ip for security decisions — it’s a logging convenience, not an authenticated identity. Restrict direct access to the port with a firewall rule that allows only the load balancer’s and health check’s source ranges:
gcloud compute firewall-rules create allow-gatekeeper-lb \
--network=default \
--direction=INGRESS \
--action=ALLOW \
--rules=tcp:9080,tcp:5432 \
--source-ranges=130.211.0.0/22,35.191.0.0/16 \
--target-tags=gatekeeper
Adjust the tcp: ports to whichever listeners you’ve fronted with the load balancer — omit 5432 if the Postgres listener isn’t load-balanced (or isn’t configured at all).
Verification
Before enabling proxy_protocol, the canonical log line shows the load balancer’s address as client_ip:
level=INFO msg=request http_host=api.github.com client_ip=35.191.12.34 credential_injected=true
After enabling proxy.proxy_protocol: true on gatekeeper and --proxy-header=PROXY_V1 on the backend service, the same request logs the real client:
level=INFO msg=request http_host=api.github.com client_ip=203.0.113.7 credential_injected=true
The Postgres listener’s canonical log line behaves the same way once postgres.proxy_protocol: true is set and the fronting load balancer sends the header:
level=INFO msg=request proxy_type=postgres client_ip=203.0.113.7 credential_injected=true
A connection whose PROXY header fails to parse — a malformed v1 line or truncated v2 binary header — is dropped, and gatekeeper logs one line at DEBUG, on either listener:
level=DEBUG msg="dropping connection: malformed PROXY protocol header" peer=35.191.12.34:51422 err="proxyproto: ..."
Health check notes
Gatekeeper’s fail-open policy means a load balancer health check keeps working whether or not it carries a PROXY header: a probe that includes one is parsed normally, and a probe that doesn’t falls back to the raw TCP peer address. Neither case is rejected, so flipping proxy_protocol on and off does not require a matching change to the health check configuration. This holds for both the HTTP listener’s /healthz endpoint and a bare TCP connect check against the Postgres listener.
On the shared-port setup, /healthz still works exactly as it does on a dedicated HTTP listener: an HTTP health check’s request line is classified as HTTP traffic (it isn’t a Postgres startup signature) and routed to the same handler that serves /healthz on a distinct port. A bare TCP connect-then-close probe — the kind a plain TCP health check performs — never sends enough bytes to be classified either way, so it’s simply dropped once the classification read times out, the same clean, silent outcome as connecting and disconnecting from any other gatekeeper listener.
Next steps
- Postgres data plane — how the Postgres listener authenticates clients and resolves upstream credentials
- Observability — how
client_ipand other request attributes flow into logs, traces, and metrics - Config file — full
proxy.proxy_protocolandpostgres.proxy_protocolfield reference - OpenTelemetry — correlate
client_ipwith traces and metrics