Multi-Tenancy¶
This guide describes how to run multiple tenants (teams, namespaces) behind one controller installation, what each isolation layer guarantees, and where the boundaries are.
The isolation model at a glance¶
| Threat | Fail-fast layer | Authoritative layer |
|---|---|---|
| A tenant claims another tenant's hostname | ValidatingAdmissionPolicy rejects the write at admission | The controller refuses to program the route into the proxy and the tunnel |
| A route is silently shadowed by a higher-precedence route | cf.k8s.lex.la/RouteShadowed condition + Warning Event | Hostname ownership makes cross-tenant shadowing impossible |
| Tenant listeners escape operator control | allowedListeners / allowedRoutes admission status | The same filters are enforced in the data path (merge view) |
| Tenants share one process and one tunnel | Listener scoping (this page) | A dedicated proxy and tunnel per Gateway — see Per-Gateway Isolation |
Every protection ships as two independent layers by design: if one layer is bypassed (an older cluster without ValidatingAdmissionPolicy, a deleted policy, a write path admission does not gate), the other still holds.
The hostname-capture problem¶
The Gateway API defines no route-to-route hostname ownership. When one listener with no hostname pin allows routes from all namespaces, any namespace can create an HTTPRoute claiming any hostname — including one another team already serves. Route precedence (oldest creationTimestamp wins, then alphabetical {namespace}/{name}) then decides who receives the traffic, silently.
The spec-aligned answer is scoping listeners per tenant; the sections below layer enforcement on top.
Canonical pattern: per-tenant listeners¶
Pin each tenant to its hostname suffix with a dedicated listener and restrict which namespaces may bind to it:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: cloudflare-tunnel-system
spec:
gatewayClassName: cloudflare-tunnel
listeners:
- name: team-a
port: 443
protocol: HTTPS
hostname: "*.team-a.example.com"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
tenant: team-a
- name: team-b
port: 443
protocol: HTTPS
hostname: "*.team-b.example.com"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
tenant: team-b
The controller enforces listener-to-route hostname intersection, so a route in a tenant: team-a namespace cannot serve app.team-b.example.com through the team-a listener. Avoid the permissive combination — a single unpinned listener with allowedRoutes.namespaces.from: All — unless every namespace is equally trusted. The controller surfaces that exact combination on the listener as a cf.k8s.lex.la/PermissiveHostname=True advisory condition (reason UnpinnedHostnameAllowsAllNamespaces), so the capture surface is visible in kubectl describe gateway, not just flagged here. The listener stays Accepted (the combination is legal); pin its hostname or scope allowedRoutes and the condition clears.
Tenant self-service with ListenerSet¶
Instead of the platform team editing the Gateway for every tenant, delegate listener management via ListenerSet: the Gateway opts in with allowedListeners.namespaces.from: Selector, and each tenant owns a ListenerSet (its hostnames, its own TLS certificate references) in its namespace. Conflicting listeners are rejected with Conflicted=True following GEP-1713 precedence (Gateway listeners first, then oldest creationTimestamp, then {namespace}/{name}).
A tenant-authored ListenerSet entry is the highest-risk place for the hostname-capture combination, since the entry author may be untrusted. The controller raises the same cf.k8s.lex.la/PermissiveHostname=True advisory on a ListenerSet entry that combines allowedRoutes.namespaces.from: All with no hostname pin, exactly as it does for a Gateway-owned listener.
Note the TLS boundary: per-ListenerSet TLS certificate references are validated for status (ResolvedRefs, including ReferenceGrant for cross-namespace refs) but never served — TLS terminates at the Cloudflare edge with Cloudflare's certificates. Parent listener secrets are never readable through a child ListenerSet.
Enforcing hostname ownership¶
The hostnameOwnershipPolicy Helm value binds each namespace to ONE allowed hostname suffix via a namespace label and enforces it twice:
# values.yaml
hostnameOwnershipPolicy:
enabled: true
labelKey: cf.k8s.lex.la/hostname-suffix
namespaceSelector:
matchLabels:
tenancy: enforced
apiVersion: v1
kind: Namespace
metadata:
name: team-a
labels:
tenancy: enforced
cf.k8s.lex.la/hostname-suffix: team-a.example.com
With this in place:
- Admission layer (Kubernetes 1.30+): a
ValidatingAdmissionPolicydeniesHTTPRoute/GRPCRoutewrites whose hostnames fall outside the namespace suffix. On older clusters sethostnameOwnershipPolicy.admissionPolicy: falseto skip this layer. - Controller layer: independent of admission, the controller rejects violating routes at binding time (
Accepted=False, reasonHostnameNotPermitted) and never programs them into the proxy config or the Cloudflare ingress document.
Both layers are fail-closed within the policed scope:
- A policed namespace without the suffix label cannot program any route.
- A route without explicit
spec.hostnamesis rejected — an empty list would inherit the listener hostname, which is exactly the capture vector. evil<suffix>does not pass: a hostname must equal the suffix or be a subdomain of it (.-boundary).
Model constraints: label values cannot contain * or , and cap at 63 characters (hostnames go to 253 — a longer suffix is not expressible as a label value), so each namespace gets exactly one suffix; uppercase in the label value is normalized. An empty namespaceSelector polices every namespace — fail-closed everywhere, including system namespaces — so scope it deliberately (a marker label as above, or matchExpressions excluding kube-system and the controller namespace).
The suffix lives in a Namespace label — withhold Namespace write from tenants
The allowed hostname suffix is the namespace's own label value. A tenant who can patch labels on their own Namespace can rewrite that suffix and authorize any hostname — the entire ownership boundary collapses. This policy assumes tenants cannot write their Namespace object: grant update/patch on namespaces (and the suffix label specifically) to cluster operators only, never to tenant ServiceAccounts. The same applies to who may set the suffix label at namespace-creation time.
Scope difference between the two layers
The admission policy matches every HTTPRoute/GRPCRoute written in a policed namespace, regardless of which Gateway implementation the route targets — admission cannot resolve parentRefs. The controller layer polices only routes binding to THIS controller's Gateways. On a cluster running multiple Gateway API implementations (Istio, Envoy Gateway, …), enabling the admission layer will also constrain routes meant for those implementations; scope namespaceSelector to namespaces that only use this controller, or run with admissionPolicy: false and rely on the controller layer alone.
Detecting collisions¶
Same-hostname routes merge legally per the Gateway API, so a collision is not an error — but it should never be invisible. When a route's (hostname, match) pair is exactly claimed by a higher-precedence route, the losing route carries a dedicated condition (its Accepted stays True):
$ kubectl get httproute capture-attempt -o yaml
...
conditions:
- type: cf.k8s.lex.la/RouteShadowed
status: "True"
reason: HostnameMatchShadowed
message: 'rule 0 match (host "app.team-a.example.com", ...) is shadowed by
HTTPRoute team-a/app rule 0 (older creationTimestamp); ...'
A Warning Event with reason RouteShadowed mirrors the condition for kubectl events and event-driven alerting. The condition clears automatically when the collision is resolved.
Shadow detection is exact-match only
The condition fires when one route's (hostname, match) pair is exactly claimed by a higher-precedence route. It does NOT detect overlapping-but-non-identical hostnames: a route on *.example.com and a route on *.app.example.com both legally match x.app.example.com, but neither pair is identical, so no RouteShadowed condition is raised even though the request resolution is ambiguous between them. Wildcard overlap across tenants is therefore invisible to this signal — enforce hostname ownership (hostnameOwnershipPolicy) to keep a tenant's wildcards inside its own suffix instead of relying on collision detection to catch the overlap after the fact.
The shared-plane boundary¶
Everything above is admission- and control-plane-level isolation: all tenants still share one proxy process and one Cloudflare Tunnel. That means shared fate (a crash or overload affects everyone), a shared edge identity, and no per-tenant traffic accounting at the tunnel level.
For hard isolation, opt a Gateway into a dedicated data plane — its own proxy Deployment and its own tunnel — via Gateway.spec.infrastructure.parametersRef. See Per-Gateway Isolation.
Per-tenant observability¶
The proxy exposes request-level Prometheus metrics labelled by matched hostname pattern (bounded cardinality — never the raw client Host), so per-tenant request rates, latencies, and error classes are visible on the shared plane too. See Metrics & Alerting.
Checklist¶
| Step | Mechanism |
|---|---|
| Pin listeners per tenant | listener.hostname + allowedRoutes: Selector |
| Delegate listener self-service | allowedListeners: Selector + tenant ListenerSet |
| Enforce hostname ownership twice | hostnameOwnershipPolicy.enabled: true + namespace labels |
| Watch for collisions | cf.k8s.lex.la/RouteShadowed condition / RouteShadowed Events |
| Hard-isolate critical tenants | Per-Gateway data plane |