When we moved from Ingress to Gateway API, I expected the usual migration pain: new resource kinds, different annotations, a few broken routes. What I didn’t expect was losing something so basic that I hadn’t even thought about it as a feature.

With Ingress, a developer dropped a manifest in their namespace, added cert-manager.io/cluster-issuer: letsencrypt, and walked away. cert-manager saw it, issued a certificate, done. The developer never talked to the platform team.

With Gateway API, that certificate lives on the Gateway object. Which lives in the platform namespace. Which is managed by the platform team. Want a cert for paste.k8s.one? Open a ticket. Wait for a merge. Hope nobody fat-fingers the shared Gateway config while they’re at it.

I’ve been the platform team fielding those tickets. It’s not fun for anyone.

Three eras of “who owns the certificate”#

This is easier to understand visually.

Ingress: everything in one namespace#

graph TB subgraph NS["Application Namespace"] ING["Ingress
host: app.example.com
tls: app-tls"] SVC["Service"] SEC["Secret: app-tls
(created by cert-manager)"] CERT["Certificate
(created by cert-manager)"] end CM["cert-manager"] CM -->|watches annotation| ING CM -->|creates| CERT CERT -->|produces| SEC ING -->|references| SEC ING -->|routes to| SVC style NS fill:#2d5a3d,stroke:#4a9,color:#fff style CM fill:#4a6fa5,stroke:#6a9fd5,color:#fff

Simple. Developer owns everything. But Ingress had real limits: no TCP/UDP, no traffic splitting, no header matching, and every controller extended the API differently with its own annotations.

Gateway API: better architecture, worse self-service#

Gateway API fixed the infra problems with a clean split: platform team manages the Gateway, developers manage HTTPRoutes. Great model. Except the TLS termination point moved to the Gateway:

graph TB subgraph PLATFORM["Platform Namespace"] GW["Gateway
listener: HTTPS :443
tls: wildcard-cert"] SEC["Secret: wildcard-cert"] end subgraph APP["Application Namespace"] HR["HTTPRoute
host: app.example.com
parentRef: Gateway"] SVC["Service"] end GW -->|terminates TLS with| SEC HR -->|attaches to| GW HR -->|routes to| SVC style PLATFORM fill:#5a3d5a,stroke:#9a6a9a,color:#fff style APP fill:#2d5a3d,stroke:#4a9,color:#fff

The developer can create HTTPRoutes, but can’t bind hostnames, can’t configure TLS, can’t touch certificates. In an org with dozens of teams, the platform team becomes a human certificate vending machine.

ListenerSets: self-service is back#

GEP-1713 introduced ListenerSets: developers can attach their own listeners to an existing Gateway, from their own namespace. The platform team controls who can attach (via allowedListeners on the Gateway). Developers control what they attach.

graph TD GW["🔒 Gateway\n:80 + :443\nallowedListeners: All"] CM["cert-manager"] GW -- allows --> LS1 GW -- allows --> LS2 subgraph ns1 ["Team A Namespace"] LS1["ListenerSet\npaste.example.com"] -.-> SEC1["Secret\npaste-tls"] HR1["HTTPRoute"] --> SVC1["Service"] end subgraph ns2 ["Team B Namespace"] LS2["ListenerSet\napi.example.com"] -.-> SEC2["Secret\napi-tls"] HR2["HTTPRoute"] --> SVC2["Service"] end CM -- issues --> SEC1 CM -- issues --> SEC2 LS1 --> HR1 LS2 --> HR2 style GW fill:#5a3d5a,stroke:#9a6a9a,color:#fff style CM fill:#4a6fa5,stroke:#6a9fd5,color:#fff style ns1 fill:#2d5a3d,stroke:#4a9,color:#fff style ns2 fill:#2d4a5a,stroke:#4a8a9a,color:#fff

Each team’s TLS secrets stay in their namespace. A ReferenceGrant on the Gateway doesn’t leak to ListenerSets, and vice versa. Clean blast radius.

Version check before you start#

ListenerSet went through a naming evolution. The experimental version was XListenerSet under gateway.networking.x-k8s.io/v1alpha1. The GA version graduated to ListenerSet under gateway.networking.k8s.io/v1 with Gateway API v1.5. If you’re searching for issues and find references to XListenerSet, that’s the old experimental API, same concept, different name.

This matters because not all versions of your gateway controller and cert-manager speak the same variant:

ComponentXListenerSet (experimental)ListenerSet (GA)
Envoy Gateway v1.7.xYes (opt-in)No
Envoy Gateway v1.8.0+DeprecatedYes
cert-manager v1.20+NoYes (opt-in)

If you’re on Envoy Gateway v1.7.x and cert-manager v1.20, you have a version mismatch: one speaks experimental, the other speaks GA. Save yourself the headache and upgrade Envoy Gateway to v1.8.0+. That’s what I did, and it’s what this guide assumes.

Setting it up: Envoy Gateway + cert-manager + podinfo#

Here’s the full working stack. I’m using podinfo as the sample app because it’s lightweight and has a health endpoint. For simplicity, we’ll use a self-signed ClusterIssuer. Swap it with Let’s Encrypt or any ACME issuer when you’re ready for production.

graph TD subgraph CM_NS["cert-manager namespace"] CI["ClusterIssuer\nselfsigned"] CM["cert-manager\n--feature-gates=ListenerSets=true"] end subgraph GW_NS["envoy-gateway-system namespace"] EG["Envoy Gateway\nv1.8.0+"] GW["Gateway\n:80 + :443\nallowedListeners: All"] EG -.-> GW end subgraph APP_NS["podinfo namespace"] LS["ListenerSet\npodinfo.example.com\nannotation: cluster-issuer"] CERT["Certificate\npodinfo-tls"] -->|produces| SEC["Secret\npodinfo-tls"] SEC -.-> LS LS --> HR["HTTPRoute"] --> SVC["Service\n:9898"] --> POD["podinfo"] end CM -->|watches ListenerSet\ncreates Certificate| CERT CI -.->|signs| CERT GW -->|allows| LS style CM_NS fill:#4a4a6a,stroke:#7a7aaa,color:#fff style GW_NS fill:#5a3d5a,stroke:#9a6a9a,color:#fff style APP_NS fill:#2d5a3d,stroke:#4a9,color:#fff

1. Envoy Gateway#

GA ListenerSet support landed in Envoy Gateway v1.8.0. The chart bundles all Gateway API CRDs including listenersets.gateway.networking.k8s.io, so you don’t need to install them separately.

helm install envoy-gateway oci://docker.io/envoyproxy/gateway-helm \
  --version v1.8.0-rc.0 \
  --namespace envoy-gateway-system \
  --create-namespace

2. cert-manager + ClusterIssuer#

cert-manager v1.20 has a ListenerSet controller, but it’s disabled by default. You need two things: the enableGatewayAPIListenerSet config flag, and the ListenerSets feature gate.

helm install cert-manager cert-manager \
  --repo https://charts.jetstack.io \
  --version v1.20.2 \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true \
  --set featureGates="ListenerSets=true" \
  --set config.enableGatewayAPI=true \
  --set config.enableGatewayAPIListenerSet=true

Watch out: featureGates must be a top-level Helm value. If you put it inside the config block as a ControllerConfiguration field, it gets silently ignored. No error, no warning. cert-manager just starts without the ListenerSet controller, and you’ll stare at skipping disabled controller: listenerset in the logs wondering what went wrong.

Also: cert-manager will crash on startup if you enable the feature gate but the ListenerSet CRD doesn’t exist yet. Install Envoy Gateway (which bundles the CRD) before deploying cert-manager, or install the CRD manually first.

Now create a self-signed ClusterIssuer. In production you’d use Let’s Encrypt or your corporate CA, but this keeps the example self-contained:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}

3. The Gateway#

The Gateway needs allowedListeners to permit ListenerSets from other namespaces. Without this, ListenerSets will be rejected:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: external-gateway
  namespace: envoy-gateway-system
spec:
  gatewayClassName: envoy-gateway
  allowedListeners:
    namespaces:
      from: All
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      port: 443
      protocol: HTTPS
      allowedRoutes:
        namespaces:
          from: All
      tls:
        mode: Terminate
        certificateRefs:
          - name: default-gateway-cert

from: All opens ListenerSet creation to every namespace. In production, you’ll probably want from: Selector with a label like gateway-listeners: enabled to restrict which teams can attach.

4. The application: podinfo + ListenerSet + HTTPRoute#

This is the part the developer owns. Everything goes in their namespace, no platform team involvement needed:

helm install podinfo oci://ghcr.io/stefanprodan/charts/podinfo \
  --namespace podinfo --create-namespace \
  --set service.type=ClusterIP
apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
  name: podinfo
  namespace: podinfo
  annotations:
    cert-manager.io/cluster-issuer: selfsigned
spec:
  parentRef:
    name: external-gateway
    namespace: envoy-gateway-system
  listeners:
    - name: https
      hostname: podinfo.example.com
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - name: podinfo-tls
    - name: http
      hostname: podinfo.example.com
      port: 80
      protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: podinfo
  namespace: podinfo
spec:
  parentRefs:
    - name: podinfo
      kind: ListenerSet
      namespace: podinfo
  hostnames:
    - podinfo.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: podinfo
          port: 9898

One thing to get right: the HTTPRoute’s parentRef points to the ListenerSet, not the Gateway. The hostname lives on the ListenerSet now, so if your route references the Gateway directly you’ll get a NoMatchingListenerHostname error and a 404.

Apply the manifests and cert-manager takes over: it sees the cert-manager.io/cluster-issuer annotation on the ListenerSet, creates a Certificate, signs it with the self-signed issuer, and populates the podinfo-tls Secret. Envoy Gateway programs the listener with TLS termination. The HTTPRoute attaches and routes traffic to podinfo.

The developer never touched the Gateway. The platform team never touched the application namespace. Just like the Ingress days, but with better infrastructure under the hood.

5. Verify it works#

Wait a few seconds for cert-manager to issue the certificate, then check:

# Verify the certificate is ready
kubectl get certificate -n podinfo
# NAME          READY   SECRET        AGE
# podinfo-tls   True    podinfo-tls   30s

# Extract the CA cert for curl (self-signed, so we need to trust it)
kubectl get secret podinfo-tls -n podinfo \
  -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/podinfo.crt

# Get the gateway's external IP
GATEWAY_IP=$(kubectl get gateway external-gateway \
  -n envoy-gateway-system \
  -o jsonpath='{.status.addresses[0].value}')

# Hit the endpoint
curl --cacert /tmp/podinfo.crt \
  --resolve podinfo.example.com:443:${GATEWAY_IP} \
  https://podinfo.example.com
{
  "hostname": "podinfo-5d76864db6-7n2jl",
  "version": "6.7.1",
  "revision": "",
  "color": "#34577c",
  "logo": "https://raw.githubusercontent.com/stefanprodan/podinfo/gh-pages/cuddle_clap.gif",
  "message": "greetings from podinfo v6.7.1"
}

Self-signed certificate, issued by cert-manager, served by Envoy Gateway, all triggered by a single ListenerSet annotation. Replace selfsigned with your ACME ClusterIssuer and the flow is identical, just with a real certificate.

Things that will bite you#

SymptomWhat’s wrongFix
Waiting for controller on ListenerSetEnvoy Gateway too old or not configuredUpgrade to v1.8.0+. On v1.7.x you need gatewayAPI.enabled: [XListenerSet] but then cert-manager won’t cooperate
skipping disabled controller: listenersetcert-manager feature gate in the wrong placefeatureGates is a top-level Helm value, not inside config
cert-manager CrashLoopBackOffListenerSet CRD not installedDeploy Envoy Gateway first (it bundles the CRD), then cert-manager
NoMatchingListenerHostname / 404HTTPRoute points to Gateway instead of ListenerSetChange parentRef to kind: ListenerSet
cert-manager ignores your XListenerSetcert-manager v1.20 only supports the GA APIUpgrade Envoy Gateway to v1.8.0+ or install the GA CRD from upstream

Why I’ve been waiting for this#

ListenerSets restore a property that made Kubernetes networking practical in the Ingress days: developers can deploy an application end-to-end without waiting on another team.

The platform team still controls ports, namespaces, and infrastructure. Developers control their hostname, their certificate, their routing. ReferenceGrants keep each namespace’s secrets isolated: a grant on the Gateway doesn’t leak to ListenerSets, and vice versa.

This is the separation of concerns that Gateway API promised from day one. ListenerSets are what finally delivers it.

References#