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:
- You can now have an unlimited number of listeners (if your implementation can handle it!), each with their own domain.
- You can now have a different namespace for your
Gatewayand yourListenerSet, 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:
- ingress2gateway tool to help automate the migration
- The implementations page and Gateway API Benchmarks to help pick an implementation.