With the retirement of ingress-nginx I've seen a lot of frustrations with Gateway API migrations due to differences in some of the resource models.

The common problem is that Ingress users are often running self-service models, where application teams fully own their ingress configuration, including TLS certificates.

This would look something like so:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: my-issuer
  name: app-a-routes
  namespace: app-a
spec:
  rules:
  - host: app-a.example.com
    http:
      paths:
      - pathType: Prefix
        path: /
        backend:
          service:
            name: myservice
            port:
              number: 80
  tls:
  - hosts:
    - app-a.example.com
    secretName: myingress-cert

Combined with cert-manager a certificate would automatically be provisioned for app-a.example.com and linked up to the shared Nginx instance. DNS would then be handled with wildcard entries or external-dns.

The Gateway ownership problem

In Gateway API, you can almost do the same but with a subtle but important difference.

In Gateway, there is a split resource model between a Gateway and Routes; Gateway is where TLS is defined.

This looks as follows:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-gateway
  namespace: gateway
  annotations:
    cert-manager.io/cluster-issuer: my-issuer
spec:
  gatewayClassName: my-gateway-class
  listeners:
  - name: https
    protocol: HTTPS
    port: 443
    hostname: app-a.example.com
    tls:
      mode: Terminate
      certificateRefs:
      - kind: Secret
        name: myingress-cert
    # Cool, new scoping feature.
    allowedRoutes:
      namespaces:
        from: Selector
        selector:
          matchLabels:
            kubernetes.io/metadata.name: app-a
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: app-a-routes
  namespace: app-a
spec:
  parentRefs:
  - name: my-gateway
    namespace: gateway
  hostnames:
  - app-a.example.com
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: myservice
      port: 80

The critical change here is that the TLS configuration for app-a moved from the app-a namespace into the shared gateway namespace. Application teams can no longer self-service!

On top of this, even without this multi-tenancy requirement, a Gateway can only have up to 16 listeners. This means that you are forced to use wildcard certificates if you have more than a handful of domains.

Enter ListenerSets

The ListenerSet is the new solution to these problems, going GA in Gateway API v1.5, released in Feb 2026 just in time for the ingress-nginx retirement.

The idea of ListenerSet is to split listeners out of a Gateway, solving both issues above:

  1. You can now have an unlimited number of listeners (if your implementation can handle it!), each with their own domain.
  2. You can now have a different namespace for your Gateway and your ListenerSet, bringing back the self serve use case.

What you don't get back is the single-resource simplicity of Ingress (quite the opposite; there are now 3 resources), but it is what it is.

Putting them to the test

As ListenerSet is pretty new I was only able to find 2 implementations with support - Kgateway, and Agentgateway - so I flipped a coin and landed on demoing with Agentgateway.

Let's try it out!

First, we will get everything installed:

$ kubectl kustomize https://github.com/kubernetes-sigs/gateway-api/config/crd?ref=v1.5.1 | kubectl apply --server-side -f -
$ helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set config.enableGatewayAPI=true \
  --set config.enableGatewayAPIListenerSet=true \
  --set config.featureGates.ListenerSets=true \
  --set crds.enabled=true
$ helm upgrade -i --create-namespace --namespace agentgateway-system agentgateway-crds --version 1.0.1 oci://cr.agentgateway.dev/charts/agentgateway-crds
$ helm upgrade -i --namespace agentgateway-system agentgateway --version 1.0.1 oci://cr.agentgateway.dev/charts/agentgateway

Next we can take our example above, but split out the listener into a ListenerSet in the application namespace:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: <redacted>
    privateKeySecretRef:
      name: letsencrypt-acc-key
    solvers:
      - http01:
          gatewayHTTPRoute: {}
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-gateway
  namespace: gateway
spec:
  gatewayClassName: agentgateway
  allowedListeners:
    namespaces:
      from: All
  listeners:
    # There is currently no way to set zero listeners :-(
    - name: http
      hostname: dummy
      protocol: HTTP
      port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
  name: app-a
  namespace: app-a
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  parentRef:
    name: my-gateway
    namespace: gateway
  listeners:
    - name: https
      hostname: app-a.k8s.howardjohn.info
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - name: backend-tls
    - name: http
      hostname: app-a.k8s.howardjohn.info
      port: 80
      protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: app-a-routes
  namespace: app-a
spec:
  parentRefs:
  - kind: ListenerSet
    name: app-a
    sectionName: https
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: echo
      port: 80

After some time for the certificate to be provisioned, we can hit our new workload!:

$ curl https://app-a.k8s.howardjohn.info
ServiceVersion=
ServicePort=80
Host=app-a.k8s.howardjohn.info
URL=/
Method=GET
Proto=HTTP/1.1
IP=10.34.131.5
RequestHeader=Accept:*/*
RequestHeader=User-Agent:curl/8.19.0
Hostname=echo-8fbd95d6d-fk2kt

Adding another application is much of the same, just stamped out into a new namespace:

apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
  name: app-b
  namespace: app-b
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  parentRef:
    name: my-gateway
    namespace: gateway
  listeners:
    - name: https
      hostname: app-b.k8s.howardjohn.info
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - name: backend-tls
    - name: http
      hostname: app-b.k8s.howardjohn.info
      port: 80
      protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: app-b-routes
  namespace: app-b
spec:
  parentRefs:
  - kind: ListenerSet
    name: app-b
    sectionName: https
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: echo
      port: 80

And try this one out:

$ curl https://app-b.k8s.howardjohn.info
ServiceVersion=
ServicePort=80
Host=app-b.k8s.howardjohn.info
URL=/
Method=GET
Proto=HTTP/1.1
IP=10.34.130.12
RequestHeader=Accept:*/*
RequestHeader=User-Agent:curl/8.19.0
Hostname=echo-8fbd95d6d-b54w8

Success! We are now able to self-serve TLS certificates with Gateway API.

Migrating

Here I just showed the basics of using the new ListenerSet resource in Gateway, but if you are migrating from Ingress to Gateway API I would recommend: