Skip to main content

Gateway API v1.5's ListenerSet Just Solved Multi-Tenant Custom Domains — Here's the RBAC Layout That Replaces Your Ingress Hacks

8 min readDora NodaDora Noda
Share

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:

  1. The 64-listener ceiling. A single Gateway resource 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.
  2. Issuer scoping fights multi-tenancy. cert-manager Issuer resources are namespace-scoped; ClusterIssuer is 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.
  3. 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:

yaml
# 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
yaml
# 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-secret

allowedListeners.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:

  1. Listeners on the parent Gateway itself always win over any ListenerSet.
  2. Between competing ListenerSets, the one with the earliest creation timestamp wins.
  3. If timestamps tie, the alphabetically first ListenerSet name 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.

yaml
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 resourceNames

Bind 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:

yaml
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

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex