For years, giving every tenant on a multi-tenant platform their own custom domain meant one of two bad options: a bespoke controller that watches a database and reconciles Ingress objects for you, or granting tenant workloads write access to a cluster-wide Gateway object that every other tenant's traffic also depends on. Gateway API v1.5, released February 27, 2026, closes that gap with a GA primitive called ListenerSet — and it's arriving at the exact moment roughly half of all cloud-native environments are being forced off Ingress-NGINX anyway, since that controller's maintenance window closed the same month.
Below is the concrete before/after: the Ingress+cert-manager pattern most platforms run today, the ListenerSet YAML that replaces it, the precedence rules that keep tenants from clobbering each other, and — the part most writeups skip — the RBAC and admission-policy layout that actually makes "give every tenant their own domain" safe rather than just self-service.
The pattern you're probably running today, and why it breaks
The standard way to give tenants custom domains on a Kubernetes-based PaaS looks like this: one Ingress object per tenant, a cert-manager.io/cluster-issuer annotation pointing at Let's Encrypt, and a controller (or your own webhook) that creates both whenever a tenant adds a domain. It works, but it has three structural problems that only show up at scale:
- The 64-listener ceiling. A single
Gatewayresource is capped at 64 listeners. If you're modeling "one listener per tenant hostname" directly on a shared Gateway — the natural Gateway API migration path from per-tenant Ingress — you hit that ceiling around tenant 64, long before you hit any real infrastructure limit. - Issuer scoping fights multi-tenancy. cert-manager
Issuerresources are namespace-scoped;ClusterIssueris cluster-wide. Neither maps cleanly onto "each tenant manages their own certs, but no tenant can see another tenant's Issuer" — you end up either over-provisioning ClusterIssuers or writing custom RBAC per Issuer. - The real security hole: write access to a shared object. If tenants are going to self-serve a new hostname without filing a ticket to the platform team, something running with tenant-level permissions has to be able to add a listener to the Gateway. Before Gateway API v1.5, that Gateway was one object — so "let tenant A add their own hostname" and "let tenant A rewrite tenant B's listener" were the same permission. There was no way to grant one without the other.
That third problem is the one that actually matters for a multi-tenant PaaS, and it's the one ListenerSet was built to solve.
What ListenerSet actually is
ListenerSet (GEP-1713) is a new Gateway API kind, GA in the Standard channel as of v1.5.0, that decouples listener configuration — ports, hostnames, TLS certs — from the central Gateway object. A platform team owns one shared Gateway; tenants attach their own ListenerSet resources to it, each declaring only their own hostname and cert. Concretely:
# Platform-owned, in the platform's namespace
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: platform-system
spec:
gatewayClassName: envoy-gateway
allowedListeners:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: tenant-domains
listeners:
- name: platform-default
protocol: HTTPS
port: 443
hostname: "*.apps.example.com"
tls:
certificateRefs:
- name: platform-wildcard-cert# Tenant-owned, in the tenant's own namespace (labeled gateway-access: tenant-domains)
apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
name: acme-corp-domain
namespace: tenant-acme-corp
spec:
parentRef:
name: shared-gateway
namespace: platform-system
listeners:
- name: acme-custom-domain
protocol: HTTPS
port: 443
hostname: "app.acmecorp.com"
tls:
certificateRefs:
- name: acme-corp-tls-secretallowedListeners.namespaces.from is the gate: None (default — no ListenerSets accepted), Same (only the Gateway's own namespace), All (any namespace in the cluster), or Selector (namespaces matching a label, which is what a real multi-tenant platform wants — grant it per-tenant, not cluster-wide). The tenant's ListenerSet never touches the Gateway object at all; it references it by name. That reference is the whole trick: the tenant needs permission to create a ListenerSet in their own namespace, and nothing more.
This same release also promoted TLSRoute (SNI-based TLS passthrough — useful for tenants who terminate their own TLS rather than handing the platform their cert) and ReferenceGrant to the Standard channel, closing out the last cross-namespace-reference gap for that flow. Both matter for a self-hosted PaaS, but ListenerSet is the one that changes the custom-domain story.
Precedence rules: what happens when two tenants collide
Because multiple ListenerSets attach to the same Gateway, Gateway API needs deterministic rules for when two listeners aren't distinct (same port + protocol + hostname). GEP-1713 defines the order plainly:
- Listeners on the parent
Gatewayitself always win over anyListenerSet. - Between competing
ListenerSets, the one with the earliest creation timestamp wins. - If timestamps tie, the alphabetically first
ListenerSetname wins.
So if tenant-acme-corp/acme-corp-domain and tenant-widgets-inc/widgets-domain both declare hostname: app.acmecorp.com — say, widgets-inc typo'd a competitor's domain into their ListenerSet — whichever was created first silently wins the hostname, and the other tenant's listener is simply not accepted. That's deterministic, but it is not authorization. It resolves which config wins a naming collision; it says nothing about whether that tenant was allowed to claim that hostname in the first place. Skip that distinction and "safely" is just a word in the title.
The part that makes it actually safe: RBAC plus a hostname policy
Getting from "ListenerSet exists" to "tenants can safely self-serve custom domains" takes two separate controls, not one:
1. Namespace-scoped RBAC — controls who can create a ListenerSet at all.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: tenant-domain-manager
namespace: tenant-acme-corp
rules:
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["listenersets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update"]
resourceNames: [] # scoped by namespace, not resourceNamesBind this per-tenant-namespace via a RoleBinding, and pair it with a cluster-scoped Role — held only by the platform team — that's the sole holder of write access to gateways in platform-system. That split is what ListenerSet buys you over the pre-v1.5 world: tenant self-service and platform-owned shared infrastructure are now two different RBAC objects instead of one shared write permission.
2. A hostname-ownership admission policy — controls which hostname a tenant is allowed to claim.
RBAC alone doesn't stop the collision case above: any tenant with the Role granted can put any hostname string into their ListenerSet, including one they don't own DNS for. The fix is a ValidatingAdmissionPolicy that reads an allow-list off the tenant's own namespace and rejects anything outside it:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: listenerset-hostname-ownership
spec:
matchConstraints:
resourceRules:
- apiGroups: ["gateway.networking.k8s.io"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["listenersets"]
paramKind:
apiVersion: v1
kind: Namespace
validations:
- expression: >
object.spec.listeners.all(l,
namespaceObject.metadata.annotations['tenant.example.com/allowed-domains']
.split(',').exists(d, l.hostname == d))
message: "hostname not in this tenant's allowed-domains annotation"The tenant.example.com/allowed-domains annotation is set by the platform's own domain-verification flow (DNS TXT challenge, or whatever the platform already uses to confirm a tenant controls a domain before adding it) — the same verification step every custom-domain feature needs regardless of the underlying Gateway mechanism. ListenerSet doesn't remove that step; what it removes is the need for a bespoke controller to enforce everything downstream of verification. RBAC plus this policy is the actual "safely" in "give every tenant their own domain, safely" — ListenerSet alone gets you self-service, not safety.
Why this is happening now, not in some future migration cycle
None of this is optional homework for later. Ingress-NGINX — the controller behind roughly half of all cloud-native Ingress deployments — moved to best-effort-only maintenance in March 2026, with SIG Network and the Security Response Committee explicitly recommending migration to Gateway API. ingress2gateway reached 1.0 that same month, covering 30+ annotation patterns for the mechanical parts of the move. But mechanical translation only ports your existing per-tenant Ingress hack as-is — same 64-listener ceiling, same shared-write-access hole. Landing on ListenerSet natively, with the RBAC split and admission policy above, is the version of this migration that actually fixes the problem instead of relocating it.
For a self-hosted PaaS running its own Kubernetes control plane on owned infrastructure rather than a managed load-balancer product, that distinction is the whole ballgame: the difference between "tenants file a ticket for a custom domain" and "tenants add a domain through the API and it's live in seconds, without anyone on the platform team touching a shared object to make it happen."
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Per-tenant custom domains on a shared, self-hosted control plane are exactly the kind of problem Gateway API's newer primitives are built for. Star the repo on GitHub or deploy your first app today.
Sources
- Gateway API v1.5: Moving features to Stable — Kubernetes Blog
- Exploring ListenerSets in Gateway API v1.5 — howardjohn's blog
- ListenerSet — Gateway API reference
- GEP-1713: ListenerSets — Standard Mechanism to Merge Multiple Gateways
- ListenerSet user guide — Gateway API
- Security — Gateway API concepts
- Ingress NGINX Retirement: What You Need to Know — Kubernetes Blog
- The End of an Era: Transitioning Away from Ingress NGINX — Google Open Source Blog