Until August 2026, exposing a Postgres database from Kubernetes meant choosing between two bad options: burn a cloud LoadBalancer per service or hand tenants a NodePort in the 30000-32767 range and hope they remember it. On June 30, the Gateway API project shipped v1.6.0. On August 3, SIG Network confirmed the headline: TCPRoute and UDPRoute are now v1 resources on the Standard channel. No experimental CRDs, no vendor annotation, one Gateway for HTTP and raw TCP alike.
If you run a self-hosted PaaS on bare metal — a Cluster API fleet on Hetzner where every extra LoadBalancer is money and every NodePort is a support ticket — this graduation is the single most practical Gateway API change in two years. This post is the concrete map: what changed, the before/after YAML, which implementations actually support it today, and the port and TLS semantics that still bite.
What v1.6 Actually Changed (and What It Didn't)
The delta is small on purpose. Here's the scorecard:
| Item | Before v1.6 | After v1.6 |
|---|---|---|
| API version | gateway.networking.k8s.io/v1alpha2 | gateway.networking.k8s.io/v1 |
| Channel | Experimental (experimental-install.yaml) | Standard (standard-install.yaml) |
| Status | Implementation-specific, no conformance gate | Core conformance: GATEWAY-TCP / GATEWAY-UDP required to pass |
| v1alpha2 | Sole version | Deprecated, will be removed in a future release |
| Install | kubectl apply -f .../experimental-install.yaml | kubectl apply -f .../standard-install.yaml |
What the promotion means in practice:
- You can stop installing the experimental channel just for TCP. If your cluster only needed
TCPRoute/UDPRoute,standard-install.yamlatv1.6.1is now sufficient. Clusters that already run the experimental bundle keep working —v1alpha2is deprecated, not deleted — but the countdown has started. - Conformance now covers it. The Gateway API conformance suite added Core profiles for both routes. An implementation that claims Standard support must pass
TCPRouteandUDPRoutetests, which was not true when they lived in Experimental. - The shape didn't change.
TCPRouteis still deliberately thin: match byporton aGatewaylistener, forward to a backendService. No host, path, or header matching — that'sHTTPRoute's job. The graduation is about stability and portability, not new fields.
Install check:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.1/standard-install.yaml
kubectl get crd tcproutes.gateway.networking.k8s.io -o jsonpath='{.spec.versions[*].name}'
# v1 v1alpha2If you see v1, you're on the graduated API.
Before: Why NodePort and Per-Service LoadBalancers Don't Scale for a PaaS
The Kubernetes docs still describe the status quo honestly: Ingress doesn't expose arbitrary ports or protocols, so non-HTTP traffic falls back to Service of type NodePort or LoadBalancer. For a platform that hosts many tenants, both options tax the operator:
NodePort exposes on a static port 30000-32767 on every node. It works on bare metal without a cloud provider, but every tenant memorizes a different high port, firewall rules multiply, and externalTrafficPolicy and source-IP preservation become per-service tuning. It is the "just open a port" hack that never stops being a hack.
LoadBalancer per service creates a cloud load balancer (or a MetalLB allocation on bare metal) per database, per MQTT broker, per game server. On Hetzner Cloud that's a billable resource; on bare metal it's an IP from a finite pool. Ten Postgres tenants means ten LoadBalancers, ten IPs, ten health checks — before you route a single HTTP request.
Ingress annotations (the old nginx.ingress.kubernetes.io/tcp-services ConfigMap) were never portable. They tied a route to one controller's annotation dialect and didn't travel between implementations at all.
A self-hosted PaaS needs the same Gateway that fronts https://app.example.com to also front postgres.example.com:5432 — one IP, one Gateway, many listeners, shared lifecycle. That's what the graduation unlocks.
After: One Gateway, HTTP and TCP Side by Side
The model is now uniform: a Gateway declares listeners by port and protocol, *Route objects attach to a listener by parentRefs + sectionName. HTTPRoute attaches to an HTTP listener; TCPRoute attaches to a TCP listener on the same Gateway. One load-balancer IP, one set of GatewayClass policies, tenants own their own *Route objects.
Minimal end-to-end example
A Gateway with an HTTP listener for apps and two TCP listeners for Postgres and MQTT:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: paas-gateway
namespace: infra
spec:
gatewayClassName: cilium # or envoy-gateway, traefik, nginx-gateway-fabric
listeners:
- name: http
protocol: HTTP
port: 80
hostname: "*.example.com"
allowedRoutes:
namespaces:
from: All
- name: postgres
protocol: TCP
port: 5432
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
- name: mqtt
protocol: TCP
port: 1883
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"A tenant's Postgres, in its own namespace, with no cloud LoadBalancer:
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
name: tenant-a-postgres
namespace: tenant-a
spec:
parentRefs:
- name: paas-gateway
namespace: infra
sectionName: postgres
rules:
- backendRefs:
- name: postgres
port: 5432MQTT, same pattern, different listener:
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
name: tenant-a-mqtt
namespace: tenant-a
spec:
parentRefs:
- name: paas-gateway
namespace: infra
sectionName: mqtt
rules:
- backendRefs:
- name: vernemq
port: 1883And the HTTP app that was already there keeps working unchanged:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: tenant-a-web
namespace: tenant-a
spec:
parentRefs:
- name: paas-gateway
namespace: infra
sectionName: http
hostnames: ["app-a.example.com"]
rules:
- backendRefs:
- name: web
port: 8080Three observations that matter for a platform:
- No NodePort in sight. The tenant never learns a high port.
postgres.example.com:5432is the wire address — standard port, standard DNS. - One IP scales. Whether you have 5 or 50 TCP services, they multiplex behind the same Gateway IP on different listeners. On a Hetzner bare-metal fleet with MetalLB or Cilium's LB IPAM, that's one IP from the pool, not fifty.
- Namespace isolation is built in.
allowedRoutesplusReferenceGrant(when a route in one namespace targets a backend in another) is the RBAC for routing. The Gateway owner decides which tenants may attach topostgres:5432; the tenant doesn't get to hijack a listener they weren't granted.
If the tenant's backend lives in a different namespace than the route, add a ReferenceGrant in the backend's namespace:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-tcproute-to-postgres
namespace: data
spec:
from:
- group: gateway.networking.k8s.io
kind: TCPRoute
namespace: tenant-a
to:
- group: ""
kind: ServiceWhich Implementations Actually Support It Today
"Standard channel" means the CRD is stable; it doesn't mean every controller has shipped conformant code for it yet. Before you pin a GatewayClass, check the implementation's v1.6 notes.
| Implementation | Standard-channel TCPRoute v1 | Notes for a self-hosted fleet |
|---|---|---|
| Envoy Gateway | Yes — reference implementation | First to pass Core conformance for GATEWAY-TCP; start here if you have no existing controller. |
| Cilium | Yes (Cilium 1.16+ with Gateway API enabled) | Attractive on bare metal: CNI + LB IPAM + Gateway in one agent; no MetalLB needed for the Gateway IP. |
| Istio / kgateway | Yes | Choose if you already run ambient mesh; otherwise heavier than needed for pure L4 routing. |
| Traefik | Yes from v3.1+ (enable kubernetesGateway provider) | Requires Standard CRDs at v1.6.1; experimental CRDs alone will prevent the provider from starting. |
| NGINX Gateway Fabric | Yes | Natural fit if migrating from ingress-nginx (archived March 2026); similar declarative model. |
| HAProxy Ingress | Tracking issue open (Aug 2026) — experimental only | An upstream issue explicitly asks for Standard-channel TCPRoute; not yet GA. |
| Kong / Contour | Partial / Extended | Check per-implementation conformance reports; TCPRoute may lag behind HTTPRoute. |
Practical rule: if your platform already runs Cilium as CNI on Hetzner, adding Cilium's Gateway API controller is the smallest moving part for TCP exposure — no extra LB layer. If you run no CNI opinion yet, Envoy Gateway is the portable default. If you already run Traefik, upgrade CRDs to v1.6.1 Standard and enable the Gateway provider; do not stay on the old experimental bundle.
Verify conformance yourself:
# Run the conformance suite against your GatewayClass
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.1/standard-install.yaml
# then run gateway-api conformance with --gateway-class=<your-class> --conformance-profiles=GATEWAY-TCP,GATEWAY-UDPThe Sharp Edges: Ports, TLS, and Cross-Namespace Wiring
Graduation didn't remove the inherent constraints of raw TCP. Five things still bite, and a PaaS should decide them up front.
1. One port, one purpose — no host-based multiplexing
TCPRoute matches on port alone. Unlike HTTPRoute, there is no hostnames field that lets ten Postgres tenants share 5432 behind SNI or virtual hosting. If ten tenants each want Postgres on 5432, you need ten listeners on ten distinct ports, or ten Gateways on ten IPs, or a higher-level proxy (e.g., a Postgres-aware proxy that speaks the wire protocol). For MQTT, game servers, or SMTP the same holds: each logical service that needs the standard port needs its own listener port.
This is not a Gateway API bug — it's TCP. The platform's job is to make the port allocation boring: allocate from a managed range, publish gateway.example.com:15432 as the tenant's address, and never let two TCPRoutes fight over the same sectionName.
2. TLS: terminate vs. passthrough is a Gateway decision, not a Route decision
For HTTPRoute, TLS termination lives on the Gateway listener (tls.certificateRefs). For raw TCP, you have two mutually exclusive modes:
- Terminate at the Gateway:
TLSprotocol on the listener, Gateway holds the cert, traffic to the backend is plaintext. Useful when the Gateway is the trust boundary. - Passthrough:
TLSorTCPprotocol withtls.mode: Passthrough— the Gateway forwards the raw TLS bytes and the backend presents its own cert. Required when the backend (e.g., a managed Postgres that insists on its own TLS) must see the original ClientHello.
TCPRoute itself has no TLS field; it inherits whatever the listener does. Don't try to mix termination and passthrough on the same listener port — the Gateway will reject the second. If tenants need both, give them separate listeners.
3. You still need to pass the port through the cloud (or bare-metal) layer
On Hetzner bare metal with Cilium LB IPAM or MetalLB, the Gateway's Service of type LoadBalancer must actually expose every listener port. A common mistake: adding a TCP listener on 5432 to the Gateway but not opening that port on the underlying Service or host firewall. The Gateway status will show Accepted: True while the data plane is firewalled. Check kubectl get svc -n infra paas-gateway -o yaml and iptables/nftables after every listener change.
4. Cross-namespace backends require ReferenceGrant
A TCPRoute in tenant-a that targets a Service in data will fail closed without a ReferenceGrant in data. This is intentional — it prevents a tenant from hijacking another tenant's Postgres — but it means a platform that centralizes databases in a data namespace must automate the grants. The error surfaces as ResolvedRefs: False on the route status, not as a connection refused.
5. v1alpha2 deprecation is a ticking clock
If your GitOps repo still has apiVersion: gateway.networking.k8s.io/v1alpha2 for TCPRoute, it will keep applying today but will break on a future CRD upgrade. A single sed across the fleet now is cheaper than a 3 a.m. apply failure later:
grep -R "gateway.networking.k8s.io/v1alpha2" --include="*.yaml" .
# replace with gateway.networking.k8s.io/v1 for TCPRoute/UDPRouteWhat This Unlocks for a Self-Hosted PaaS on Owned Hardware
The PaaS comparison that matters isn't "Gateway API vs. Ingress" — it's "one Gateway IP vs. N LoadBalancers" and "flat hardware vs. per-service platform tax."
On a managed PaaS (Railway, Render, Fly.io), every TCP service that needs a public port is a platform feature request. Fly.io's own 2026 billing changes — volume snapshots metered from January, inter-region private networking at Machine rates from February — are reminders that the number of billed primitives on a hosted platform tends to grow. A self-hosted fleet on Hetzner with Cluster API and Gateway API v1.6 does the opposite: the Gateway is one LoadBalancer IP from MetalLB or Cilium, the node is flat monthly cost, and the 20TB included bandwidth doesn't meter east-west traffic by the gigabyte.
Concretely, a fleet that previously ran:
- 1 Gateway IP for HTTP (
*.example.com) - N NodePorts or N LoadBalancers for Postgres/MQTT/game — each with its own IP, firewall rule, and monitoring
now runs:
- 1 Gateway IP with M listeners (80, 443, 5432, 1883, 25565, ...)
- N
TCPRouteobjects, each a tenant-owned YAML that attaches to a listener the platform granted
The cost that collapses is not just money — it's operational surface. One Gateway to upgrade, one set of logs, one place to rotate certs, one GatewayClass to conformance-test after a Kubernetes minor bump. Tenants self-serve their TCPRoute without filing a ticket for a new LoadBalancer.
That is the shape bex is built for: a Cluster API fleet on machines you own where the platform's own control plane (the Gateway, the GatewayClass, the ReferenceGrant policy) is declarative YAML you can GitOps, not a vendor dashboard that grows a new billing line item every quarter.
Migration Checklist: From NodePort to TCPRoute in an Afternoon
- Upgrade CRDs —
kubectl apply -f .../standard-install.yamlatv1.6.1. Confirmtcproutes.gateway.networking.k8s.ioservesv1. - Pick a GatewayClass — Envoy Gateway for portability, Cilium if it already owns your CNI. Run
GATEWAY-TCPconformance. - Add TCP listeners — one per standard port you need (5432, 1883, etc.). Scope
allowedRoutesto labeled tenant namespaces. - Replace one service — rewrite one tenant's NodePort/LoadBalancer Postgres
ServicetoClusterIPand put aTCPRoutein front of it. Testpsql -h gateway.example.com -p 5432. - Automate ReferenceGrants — if backends live outside tenant namespaces, have your platform controller mint the grants.
- Retire the old path — delete the NodePort
Service, reclaim the LoadBalancer IP, close the firewall pinhole. grep forv1alpha2andtype: NodePortacross the repo. - Document the port contract — publish which ports the Gateway exposes and how tenants request a new one (a PR that adds a listener vs. a claim that shares an existing one).
The Gateway API's promise was always that routing would be an API, not an annotation dialect. With TCPRoute and UDPRoute on the Standard channel, that promise finally covers the services that never spoke HTTP — the databases, queues, brokers, and game servers that make a PaaS more than a static-site host. The YAML is boring, which is the point: boring is portable, testable, and cheap to run on hardware you already own.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS (and now TCP) service on machines you own. Star the repo on GitHub or deploy your first app today.