Skip to content

Envoy Gateway

Audience

PLANA staff working on ingress, routing, or security filters.

PLANA has exactly one ingress: Envoy Gateway v1.7.1, deployed as a single Gateway resource in the plana-odoo namespace, declared via the Kubernetes Gateway API. Every public-facing service in the cluster exposes itself by declaring an HTTPRoute that references this gateway.

Why one gateway

We previously ran two ingress controllers — NGINX Ingress and NGINX Gateway Fabric — at different times. Both have been removed. A single Envoy Gateway is simpler, gives us a uniform place to attach security filters (CrowdSec, Coraza), and is fully managed by the Kubernetes Gateway API rather than controller-specific annotations.

The gateway is static. Once configured, the Gateway resource itself is not modified per tenant. Only HTTPRoute resources are created and deleted as tenants come and go.

The Gateway resource

FieldValue
ResourceGateway (Kubernetes Gateway API v1)
Nameeg-gateway
Namespaceplana-odoo
Classeg (Envoy Gateway's GatewayClass)
External IP194.182.177.67 (Exoscale NLB plana-pulse-eg-lb)
TLSWildcard cert per second-level domain, issued by cert-manager

Listeners

Listeners are configured per second-level domain. Every supported domain has both an HTTP and an HTTPS listener; HTTP redirects to HTTPS unconditionally except for the ACME challenge path used by Let's Encrypt.

ListenerHostname patternCert
https-app*.planapulse.appwildcard, cert-manager
https-online*.planapulse.onlinewildcard, cert-manager
https-ai*.planapulse.aiwildcard, cert-manager
https-com*.planapulse.comwildcard, cert-manager
https-dev*.planapulse.devwildcard, cert-manager
http-*redirect → HTTPS

HTTPRoutes

An HTTPRoute describes how requests for one or more hostnames flow to a backend Service. PLANA's pattern is:

  • One HTTPRoute per tenant (for the Odoo workers)
  • One HTTPRoute per platform service (account portal, BOS, agents, etc.)
  • HTTPRoutes live in the same namespace as their backend Service, with parentRefs[0] pointing at eg-gateway in plana-odoo

Cross-namespace parentRef is allowed because every product namespace has a ReferenceGrant that permits HTTPRoute resources to attach to the gateway in plana-odoo.

Tenant route example

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: acme
  namespace: plana-odoo-18
spec:
  parentRefs:
    - name: eg-gateway
      namespace: plana-odoo
  hostnames:
    - acme.planapulse.app
  rules:
    - matches:
        - path: { type: PathPrefix, value: /websocket }
      backendRefs:
        - name: worker-odoo
          port: 8072
    - matches:
        - path: { type: PathPrefix, value: / }
      backendRefs:
        - name: worker-odoo
          port: 8069

worker-odoo is a single Service in each version namespace; per-tenant routing is achieved by the Host header, which Odoo's dbfilter=^%h$ setting maps to the database name.

Platform route example (BOS / account portal)

The my.planapulse.ai hostname is shared by three backends:

Path prefixBackend
/apipulse-account-api
Known SPA paths (/dashboard, /login, /team, …)pulse-account
Everything elsebos-portal (BOS workspace slugs)

Platform routes live in infra/k8s/platform-routes/httproutes.yaml and are reconciled by Flux.

Cross-cutting filters

Two filters sit on every request that reaches the gateway. Both are failClosed — if the filter is unreachable, the request is denied.

CrowdSec — ExtAuthz

yaml
kind: EnvoyExtensionPolicy
spec:
  extAuthz:
    backendRef: crowdsec-bouncer.crowdsec.svc:8080
    failOpen: false

CrowdSec maintains an in-memory list of bad-actor IPs. The bouncer is queried for every request and returns 403 if the source IP is on the list. The agent + LAPI live in the crowdsec namespace; bouncer is a Service exposed only inside the cluster.

Coraza — WAF (OWASP CRS)

yaml
kind: EnvoyExtensionPolicy
spec:
  wasm:
    image: coraza-waf:owasp-crs
    failOpen: false

Coraza implements the OWASP ModSecurity Core Rule Set as a Wasm filter. It detects common attack patterns (SQL injection, XSS, path traversal) and returns 403 on match.

Both filters are fail-closed by policy because we discovered during the 2026-05-13 incident that fail-open is the wrong default for a small team — silent bypass during a CrowdSec outage masked an unrelated bug.

Cross-namespace backends

Envoy Gateway does not resolve ExternalName Services. To route from one namespace to a backend in another, use a normal Service in the source namespace plus a ReferenceGrant in the target namespace allowing the HTTPRoute to point at the target Service directly.

The pattern we use:

yaml
# In the target namespace (where the backend Service lives):
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: allow-routes-from-plana-odoo
  namespace: pulse-account
spec:
  from:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      namespace: plana-odoo
  to:
    - group: ""
      kind: Service

Then HTTPRoutes in any namespace can backendRefs a Service across namespace boundaries explicitly.

TLS — certs and rotation

cert-manager issues wildcard certs from Let's Encrypt for each second-level domain. DNS-01 challenge against the Exoscale DNS API. The certs are renewed automatically; nothing manual is required during the normal flow.

If a cert is in trouble:

bash
kubectl -n envoy-gateway-system get certificate
kubectl -n envoy-gateway-system describe certificate <name>

A failed renewal typically shows up as an Alertmanager alert ~24h before the cert expires. The runbook is at Operations → Alert response.

What this gateway cannot do

  • It cannot serve traffic to the LB from inside the cluster — see the hairpin-NAT note in Kubernetes.
  • It cannot do TCP-level routing for non-HTTP protocols on the same Service. SMTP/IMAP for Mailu run on a separate Hetzner box, not on this gateway.
  • It cannot proxy WebSockets to a backend that does not advertise the upgrade header. Odoo's /websocket path works because worker-odoo:8072 is explicitly the WebSocket port.

Routing decisions live in code

The full set of platform HTTPRoutes is in infra/k8s/platform-routes/httproutes.yaml. Tenant HTTPRoutes are emitted by the Crossplane PLANAClient Composition — never written by hand. See Crossplane for how that works.

© PLANA Digital Ltd.