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
| Field | Value |
|---|---|
| Resource | Gateway (Kubernetes Gateway API v1) |
| Name | eg-gateway |
| Namespace | plana-odoo |
| Class | eg (Envoy Gateway's GatewayClass) |
| External IP | 194.182.177.67 (Exoscale NLB plana-pulse-eg-lb) |
| TLS | Wildcard 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.
| Listener | Hostname pattern | Cert |
|---|---|---|
https-app | *.planapulse.app | wildcard, cert-manager |
https-online | *.planapulse.online | wildcard, cert-manager |
https-ai | *.planapulse.ai | wildcard, cert-manager |
https-com | *.planapulse.com | wildcard, cert-manager |
https-dev | *.planapulse.dev | wildcard, 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 ateg-gatewayinplana-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
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: 8069worker-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 prefix | Backend |
|---|---|
/api | pulse-account-api |
Known SPA paths (/dashboard, /login, /team, …) | pulse-account |
| Everything else | bos-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
kind: EnvoyExtensionPolicy
spec:
extAuthz:
backendRef: crowdsec-bouncer.crowdsec.svc:8080
failOpen: falseCrowdSec 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)
kind: EnvoyExtensionPolicy
spec:
wasm:
image: coraza-waf:owasp-crs
failOpen: falseCoraza 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:
# 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: ServiceThen 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:
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
/websocketpath works becauseworker-odoo:8072is 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.