Skip to main content

Gateway API v1.6 Makes TCPRoute and UDPRoute Standard: Own Your PaaS's L4 Traffic Without Ingress Annotations

10 min readDora NodaDora Noda
Share
On this page

On June 30, 2026, the last good excuse for keeping raw NodePort escapes and controller-specific annotation dialects in your platform evaporated. Gateway API v1.6.0 graduated TCPRoute and UDPRoute to the Standard channel as stable v1 resources, and the v1alpha2 shapes both started life in are now deprecated with a removal clock running. If your git-push PaaS serves anything that isn't HTTP — a Postgres-compatible database, a game server, DNS, an agent protocol over raw TCP — that traffic finally has a portable, role-oriented API instead of a per-controller hack.

Here is the whole design up front: one platform-owned Gateway with typed listeners, one TCPRoute or UDPRoute per tenant workload, and a port-allocation policy so no tenant can claim another tenant's listener. The rest of this post builds it end to end, maps who actually implements v1 L4 routing today, and lists the upgrade gotchas to clear before you flip.

L4 just went Standard: what v1.6 shipped

The headline change is small enough to state in one sentence: TCPRoute and UDPRoute are now v1 resources installable from the Standard-channel bundle, and the v1alpha2 versions are deprecated in the same release. Manifests referencing gateway.networking.k8s.io/v1alpha2 keep working for now, but the countdown to removal has started, so every L4 manifest you own needs an apiVersion bump on a schedule you control rather than on the day a future release deletes the old shapes.

The graduation did not happen in isolation. v1.5.0 had already moved TLSRoute and ListenerSet to Standard as v1 alongside ReferenceGrant, which means v1.6 completes the set: every route type a platform needs — HTTP, gRPC, TLS passthrough, TCP, UDP — now ships in standard-install.yaml with no experimental bundle to justify to your security review. There is also a v1.6.1 patch release carrying the same graduated surface, and the conformance suite grew GATEWAY-TCP and GATEWAY-UDP Core profiles, so "supports L4 routes" is now a testable claim you can demand from a controller vendor instead of a README bullet.

Why Standard matters more than it sounds: experimental-channel CRDs were always a deployment smell. Installing them meant accepting alpha-version churn on the networking objects your tenants' uptime depends on, and enterprise change boards noticed. Standard-channel L4 routes remove that objection, which is exactly what makes this release the moment to design the L4 story rather than the moment to keep deferring it.

The design: one Gateway, typed routes, copy-paste YAML

The role split is the whole point of the Gateway API model. The platform team owns the Gateway (ports, protocols, TLS posture); tenant teams own Route objects that attach to listeners the platform exposes. L4 finally works the same way L7 already did. Here is a minimal but complete design for a self-hosted fleet: one edge Gateway with an HTTP listener plus a TCP listener for Postgres-compatible traffic and a UDP listener for a game server.

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: edge
  namespace: gateway-system
spec:
  gatewayClassName: edge-controller
  listeners:
    - name: web
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
    - name: postgres
      protocol: TCP
      port: 5432
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              db-access: "true"
    - name: game-udp
      protocol: UDP
      port: 7777
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              game-server: "true"

Tenants then attach typed routes to the listeners they are allowed to use. A Postgres-compatible database gets a TCPRoute; the game server gets a UDPRoute. No annotations, no sidecar ConfigMap of port mappings, no raw Service of type LoadBalancer floating outside the routing model:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
  name: tenant-db
  namespace: tenant-alpha
spec:
  parentRefs:
    - name: edge
      namespace: gateway-system
      sectionName: postgres
  rules:
    - backendRefs:
        - name: postgres
          port: 5432
---
apiVersion: gateway.networking.k8s.io/v1
kind: UDPRoute
metadata:
  name: tenant-game
  namespace: tenant-beta
spec:
  parentRefs:
    - name: edge
      namespace: gateway-system
      sectionName: game-udp
  rules:
    - backendRefs:
        - name: game-server
          port: 7777

The same pattern covers the rest of the L4 zoo: DNS over UDP on port 53, an agent protocol over a dedicated TCP port, anything where the platform terminates nothing and just needs to get bytes to the right backend. One Gateway, one typed route per workload, and the route's parentRefs plus the listener's allowedRoutes jointly answer "who is allowed to serve this port" — which is precisely the question the annotation era never answered structurally.

The L4 gap this closes

To feel the size of the change, compare what exposing a tenant TCP port took before v1.6 with what it takes now.

WorkloadBefore (annotation era)After (Gateway API v1.6)
Tenant Postgres on 5432ingress-nginx --tcp-services-configmap, a hand-maintained port map outside the Ingress modelTCPRoute attached to a TCP listener
Game server on UDPRaw NodePort/LoadBalancer Service, invisible to the routing layerUDPRoute attached to a UDP listener
Tenant DNSSame escape hatch, plus firewall rules nobody modeledUDPRoute on port 53 with namespace-scoped allowedRoutes
Agent protocol over TCPVendor annotation dialect, unportable across controllersTCPRoute or TLSRoute (SNI passthrough), portable

The "before" column is not a strawman. ingress-nginx never modeled TCP or UDP as Ingress objects at all: L4 exposure lived in a ConfigMap of port mappings bolted onto the controller's flags, in a different format per controller, with no notion of which team owned which port.

And the controller that normalized that pattern is gone: ingress-nginx retirement was announced in November 2025, maintenance ended in March 2026 with no further security patches, and roughly half of cloud-native environments were estimated to be running it at the time. The maintainers' recommendation is to migrate to the Gateway API, with ingress2gateway scaffolding the conversion — but that tooling only helps if the target API can express everything the annotations did, and until v1.6 it could not do L4 without the experimental bundle.

Cilium's 1.20 release notes put the gap bluntly: before TCPRoute/UDPRoute support, exposing a plain TCP or UDP service meant dropping out of the Gateway API model and back to raw LoadBalancer or NodePort Services. v1.6 plus a current controller closes that escape hatch for good.

Keeping tenants off each other's ports

Standard L4 routes answer "how do I expose TCP," but the multi-tenant question is harder: what stops tenant B from attaching a route to tenant A's listener, or two tenants from fighting over one port? The spec gives you detection; the allocation policy is still yours to write. Here is the checklist.

Platform owns the Gateway; tenants own Routes. Never let tenant namespaces create or edit the shared Gateway. Port numbers appear only in listener definitions, so whoever writes listeners allocates ports. Tenant RBAC should permit TCPRoute/UDPRoute in their own namespaces and nothing in gateway-system.

Scope every L4 listener with allowedRoutes. The example above uses from: Selector with match labels so only namespaces carrying db-access: "true" can attach to the Postgres listener. from: All is fine for the HTTP listener where hostnames disambiguate tenants; for L4, where the port alone is the identity, default to Selector or Same and expand deliberately. Cross-namespace backends additionally need ReferenceGrant, which is now v1 too.

Know the conflict behavior. Listeners on one Gateway must be distinct by their port, protocol, and hostname combination. If two listeners overlap, the conflict surfaces in listener status — the overlap is rejected and visible — instead of silently last-write-wins. Design your automation to watch listener Accepted conditions and alert, the same way you already watch route status.

Respect the 64-listener ceiling. A single Gateway caps at 64 listeners, which bounds how many distinct L4 ports one Gateway object can front. For most self-hosted fleets that is plenty, but if your port count grows past it, split by traffic class (one Gateway for HTTP, one for L4) rather than stuffing everything into a single object approaching the limit.

Consider ListenerSet for delegation. ListenerSet, Standard since v1.5, lets additional listeners attach to a parent Gateway without editing it — a middle path between "platform defines every port" and "tenants edit the Gateway." If your tenants legitimately need self-serve ports (preview databases, ephemeral game servers), a ListenerSet per tenant namespace with a platform-enforced port range is cleaner than either extreme.

Keep a port registry outside the cluster API. This is the honest caveat: the spec detects conflicts but does not allocate ports. Something still has to decide that tenant A gets 5432 and tenant B gets 5433 — a small allocation service, a GitOps-reviewed port map, an admission webhook enforcing ranges per namespace label. Write that policy down before the first L4 listener goes live, not after the first port fight.

Who actually implements v1 L4 today

Graduation in the spec and support in your controller are two different dates. Here is the implementation picture as of September 2026, with the exact version to ask for.

Implementationv1 L4 statusVersion / note
Envoy GatewayTCPRoute/UDPRoute via v1v1.9; install the new CRDs before upgrading
CiliumTCPRoute/UDPRoute via eBPF L4 LB1.20 (September 2026); no more LoadBalancer escape hatch
AWS Load Balancer ControllerL4 routes provision NLBv2.13.3+, built for spec v1.6.0; no TCPRoute+HTTPRoute on one Gateway
IstioSupportedCurrent ambient/sidecar Gateway API support
APISIX Ingress ControllerReads L4 routes as v12.2.0 with Gateway API 1.6 support
TraefikSpec v1.6.1 supported, but TCPRoute still consumed as v1alpha2v3.7.10; experimentalChannel still required for now
NGINX Gateway FabricPartialL4 coverage still maturing; verify per route type

Before trusting any row in a table — including this one — run the capability check the API itself provides. Every GatewayClass advertises what its controller implements in status:

bash
kubectl get gatewayclass edge-controller -o jsonpath='{.status.supportedFeatures}'

If TCPRoute and UDPRoute are absent, your controller is behind your CRDs and L4 routes will sit unreconciled no matter how correct your YAML is. And check the other direction too: controllers validate against the CRD bundle installed in the cluster, so confirm your bundle is v1.6 or later before upgrading the controller. Bundle skew is the single most common source of "it worked in staging" L4 failures.

Upgrade gotchas before you flip

Short version: the migration is an apiVersion bump surrounded by version-skew traps. Work through these in order.

  1. Bump v1alpha2 to v1 on every L4 manifest. The old version keeps serving for now, but it is deprecated and will be removed. Do the rename while it is boring, not under a removal deadline.
  2. Upgrade the CRD bundle before the controller. A cluster serving an older Standard bundle lacks the new v1 types entirely, and controllers that watch them unconditionally can crash-loop — Envoy Gateway hit exactly this with no matches for kind "BackendTLSPolicy" on clusters behind the current bundle. CRDs first, controller second, routes last.
  3. Audit controllers still reading v1alpha2. Traefik is the documented case: spec v1.6.1 support with TCPRoute still consumed from the old version, so the experimental channel flag stays on until that catches up. Your migration is not done when your YAML says v1; it is done when your controller reads v1.
  4. Check for mixed-protocol limits. The AWS Load Balancer Controller does not support TCPRoute and HTTPRoute on the same Gateway (L4 provisions an NLB, L7 an ALB). If your design puts both on one Gateway object, split it per traffic class on AWS-backed fleets.
  5. Pin your controller's supported spec version. Community operators have been bitten by CRD bundles two minors ahead of what their controller targets. Read your controller's compatibility note and install the bundle it names, not the newest bundle that exists.
  6. Ask vendors for GATEWAY-TCP/GATEWAY-UDP conformance. Core profiles exist now. A vendor claiming L4 support should be able to point at a conformance report, and that report is worth more than any feature matrix.

The last escape hatch is closing

For years the honest architecture diagram of a Kubernetes PaaS had an asterisk: HTTP went through the nice declarative routing layer, and everything else — databases, game servers, DNS, the odd binary protocol — went through a side door of annotations, ConfigMaps, and raw Services that no one modeled and everyone feared touching. Gateway API v1.6 deletes the asterisk. L4 traffic is now a first-class citizen with typed routes, portable manifests, conformance profiles, and a deprecation clock ticking on the old shapes.

The work left is operational, not speculative: bump the apiVersion, align your CRD bundle and controller, write the port-allocation policy, and move one non-HTTP workload onto a typed route to prove the path. The side door served its time. Close it.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

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