Direct (PROXY-protocol) mode

A walkthrough of setting up Envoy Gateway when your cluster’s load balancer uses PROXY protocol v2

This guide walks through setting up Envoy Gateway in a cluster where the OpenStack load balancer is configured in TCP mode with PROXY protocol v2. The load balancer prepends a PROXY header to each incoming connection carrying the real client IP. Envoy parses that header and uses it for access logs, rate limiting and X-Forwarded-For.

Note: Your ClientTrafficPolicy must set proxyProtocol.optional: false. Without it Envoy parses the load balancer’s PROXY-v2 prefix as a malformed HTTP request and every response is HTTP 400 Bad Request.

If you are not sure which variant applies to your cluster, see the Envoy Gateway overview.

The shared-Gateway layout

A cluster runs one shared Gateway in a dedicated namespace that serves routes from all your application namespaces through a single load balancer and IP. Each application namespace opts in with a label and contributes its own HTTPRoutes. This is the standard setup, the same single-entry-point model ingress-nginx gave you, where one controller fronted every host.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#DAE7EC','primaryBorderColor':'#1E343E','primaryTextColor':'#1E343E','lineColor':'#5A7A8A','clusterBkg':'#EEF3F6','clusterBorder':'#9BB3BF','edgeLabelBackground':'#FFFFFF'}}}%%
flowchart TB
    client(["Clients"]):::client -->|PROXY protocol v2| lb["OpenStack load balancer<br/>TCP mode &middot; one LB &middot; one IP"]:::lb
    lb --> gw

    subgraph gwns["gateway namespace"]
        gw["Gateway 'shared'"]:::gw
        ctp["ClientTrafficPolicy<br/>proxyProtocol.optional: false"]:::policy
        cert["TLS certificate<br/>team-a.example.com"]:::policy
    end
    ctp -.->|attaches to| gw
    cert -.->|terminates TLS| gw

    subgraph ta["team-a namespace (labelled)"]
        appa["app + HTTPRoute"]:::app
    end
    subgraph tb["team-b namespace (labelled)"]
        appb["app + HTTPRoute"]:::app
    end
    gw -->|"team-a.example.com"| appa
    gw -->|"team-b.example.com"| appb

    classDef client fill:#FFFFFF,stroke:#1E343E,color:#1E343E;
    classDef lb fill:#DAE7EC,stroke:#1E343E,color:#1E343E;
    classDef gw fill:#FBBD18,stroke:#1E343E,stroke-width:2px,color:#1E343E;
    classDef policy fill:#F5F8FA,stroke:#1E343E,color:#1E343E;
    classDef app fill:#DAE7EC,stroke:#1E343E,color:#1E343E;

What the shared Gateway gives you:

  • One load balancer per cluster: a single LB and IP front all your teams, the way a single ingress controller did before.
  • One ClientTrafficPolicy to manage, in the gateway namespace, with TLS terminated there (one certificate per hostname you serve).
  • Self-service for app teams: an app team only labels its namespace and creates an HTTPRoute; it never touches the shared Gateway.

Each Gateway provisions its own load balancer. Running more than one (a separate Gateway and load balancer for a single namespace, via allowedRoutes.namespaces.from: Same) is a non-standard setup, for the rare case that genuinely needs an isolated IP or blast radius.

Prerequisites

  • A dedicated namespace for the shared Gateway. The examples use gateway.
  • One or more application namespaces. The examples use team-a.
  • A DNS record for each hostname you serve, pointing at the load balancer’s public IP. The examples use team-a.example.com; replace it with your own throughout.
  • cert-manager in the cluster. The examples issue certificates with ACME HTTP-01, which needs no DNS-provider credentials. If you are not using our managed cert-manager, install your own.

Create the gateway namespace

kubectl create namespace gateway
kubectl label namespace gateway shared-gateway-access=true

The label lets routes created in the gateway namespace itself attach to the shared Gateway. Two gateway-owner routes need this: cert-manager’s short-lived HTTP-01 challenge route, and the HTTP-to-HTTPS redirect below.

Create the shared Gateway

Gateway describes the listeners. Put it in the dedicated gateway namespace and reference the cluster GatewayClass named eg. The allowedRoutes selector is what lets routes in other namespaces attach.

Create a file called gateway.yaml with the following content:

---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: shared
  namespace: gateway
spec:
  gatewayClassName: eg
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              shared-gateway-access: "true"
    - name: https
      port: 443
      protocol: HTTPS
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              shared-gateway-access: "true"
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: team-a-tls

The Gateway has two listeners. The https listener (port 443) terminates TLS; it has no hostname, so it serves every host whose certificate is listed in its certificateRefs, and Envoy picks the right one per request by SNI. The http listener (port 80) carries plaintext requests: it serves ACME HTTP-01 challenges and is where the HTTP-to-HTTPS redirect below attaches.

allowedRoutes.from: Selector admits routes from any namespace carrying the shared-gateway-access: "true" label; this is the opt-in that makes the Gateway shared. Use from: Same instead if you ever want a Gateway that only serves its own namespace, or from: All to admit every namespace unconditionally (not recommended, since it removes the opt-in).

Apply it: kubectl apply -f gateway.yaml

Configure proxy-protocol with ClientTrafficPolicy

The ClientTrafficPolicy attaches to the Gateway by name and tells Envoy to parse the PROXY-v2 header from the load balancer. It lives in the gateway namespace alongside the Gateway and covers the whole load balancer; app namespaces do not need their own.

Create a file called client-traffic-policy.yaml:

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: shared
  namespace: gateway
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: shared
  proxyProtocol:
    optional: false

Note: The policy must live in the same namespace as the Gateway. Envoy Gateway rejects cross-namespace policy targets.

Apply it: kubectl apply -f client-traffic-policy.yaml

Issue a TLS certificate

Terminate TLS on the shared Gateway with a certificate per hostname, issued by cert-manager into the gateway namespace (where TLS terminates). In direct mode clients reach the load balancer directly, so ACME HTTP-01 is the simplest validation: Let’s Encrypt fetches a token over port 80, which the shared Gateway already serves, and no DNS-provider credentials are needed.

Create a file called certificate.yaml:

---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: letsencrypt-http01
  namespace: gateway
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: platform@example.com
    privateKeySecretRef:
      name: letsencrypt-http01-account
    solvers:
      - http01:
          gatewayHTTPRoute:
            parentRefs:
              - group: gateway.networking.k8s.io
                kind: Gateway
                name: shared
                namespace: gateway
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: team-a-tls
  namespace: gateway
spec:
  secretName: team-a-tls
  issuerRef:
    name: letsencrypt-http01
    kind: Issuer
  dnsNames:
    - team-a.example.com

Replace the email and hostname with your own, then apply it: kubectl apply -f certificate.yaml

To solve the challenge, cert-manager creates a short-lived HTTPRoute in the gateway namespace, attached to the http listener. It is admitted because you labelled the gateway namespace when you created it. The https listener stays pending until the certificate is issued, then serves it.

Each additional hostname needs its own Certificate and a matching entry in the https listener’s certificateRefs; Envoy then selects the right certificate per request by SNI.

Prefer DNS-01? If you would rather validate over DNS (for example to keep the ACME servers off port 80), use a DNS-01 Issuer instead. DNS-01 needs API credentials for your DNS provider; see the cert-manager DNS-01 docs.

Redirect HTTP to HTTPS

ingress-nginx redirected HTTP to HTTPS for you (the ssl-redirect default). To keep that behaviour, attach one redirect HTTPRoute to the http listener, created once in the gateway namespace (already labelled above). It matches every host on port 80, so app teams do not add their own.

Create https-redirect.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: https-redirect
  namespace: gateway
spec:
  parentRefs:
    - name: shared
      namespace: gateway
      sectionName: http
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

Apply it: kubectl apply -f https-redirect.yaml

A request to http://team-a.example.com/ now returns 301 Moved Permanently with Location: https://team-a.example.com/, and the client repeats the request over HTTPS.

Does this break HTTP-01 certificates? No. This redirect matches the path /, while cert-manager’s challenge route matches the longer /.well-known/acme-challenge/ path. Gateway API gives precedence to the longest path match, so ACME challenges are still served over plain HTTP while everything else redirects.

Onboard an application namespace

This is all an app team does; no access to the gateway namespace is needed.

1. Label the namespace so the shared Gateway admits its routes:

kubectl label namespace team-a shared-gateway-access=true

2. Deploy the app. Create app.yaml:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo
  namespace: team-a
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo
  template:
    metadata:
      labels:
        app: echo
    spec:
      containers:
        - name: echo
          image: ealen/echo-server:0.9.2
          ports:
            - containerPort: 80
          env:
            - name: PORT
              value: "80"
---
apiVersion: v1
kind: Service
metadata:
  name: echo
  namespace: team-a
spec:
  selector:
    app: echo
  ports:
    - port: 80
      targetPort: 80

3. Route traffic to it. Create route.yaml. The parentRefs points at the shared Gateway in the gateway namespace, and that cross-namespace reference is what puts this app behind the shared load balancer:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: echo
  namespace: team-a
spec:
  parentRefs:
    - name: shared
      namespace: gateway
      sectionName: https
  hostnames:
    - team-a.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: echo
          port: 80

Apply both: kubectl apply -f app.yaml -f route.yaml

The route’s hostname (team-a.example.com) must have a certificate on the https listener; you issued one above. The backend Service is in the same namespace as the HTTPRoute, so no ReferenceGrant is needed; you only need one if a route points at a Service in a different namespace.

Verify

Check that the shared Gateway got an external address and that traffic flows for the app namespace:

kubectl -n gateway get gateway shared -o jsonpath='{.status.addresses[0].value}'
curl -v https://team-a.example.com/

Confirm the route attached to the shared Gateway:

kubectl -n team-a get httproute echo -o jsonpath='{.status.parents[0].conditions}'

Accepted: True and ResolvedRefs: True mean the cross-namespace attach worked. The backend should see the real client IP in X-Forwarded-For and X-Envoy-External-Address.

Common mistakes

  • Namespace not labelled: the HTTPRoute reports Accepted: False with reason NotAllowedByListeners, and traffic never reaches the app. Label the app namespace shared-gateway-access=true.
  • Forgetting namespace: in parentRefs: without it the route looks for a Gateway in its own namespace, finds none, and stays unattached. Cross-namespace routes must name the gateway namespace.
  • No certificate for the hostname: if the https listener has no certificate matching the route’s hostname, the TLS handshake fails and clients cannot connect. Issue a Certificate for each hostname and add its Secret to the listener’s certificateRefs.
  • Forgetting ClientTrafficPolicy: every request returns HTTP 400 Bad Request. The load balancer is prepending a PROXY-v2 binary header; without the policy Envoy treats those bytes as the start of an HTTP request and fails to parse it.
  • Putting ClientTrafficPolicy in another namespace: silently ignored. Must be colocated with the Gateway (here, the gateway namespace).
  • Setting proxyProtocol.optional: true: opens you up to clients that don’t send the header bypassing client-IP enforcement. Keep it false.
  • Testing with curl from outside the load balancer: PROXY-protocol traffic isn’t valid HTTP. Always go through the load balancer’s VIP.

Advanced usage

For more advanced use cases please refer to the documentation provided by each project or contact our support: