Skip to main content

Gateway API v1.6 Graduates TCPRoute to Standard: Exposing Postgres, MQTT, and Game Servers Without NodePort Hacks

13 min readDora NodaDora Noda
Share

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:

ItemBefore v1.6After v1.6
API versiongateway.networking.k8s.io/v1alpha2gateway.networking.k8s.io/v1
ChannelExperimental (experimental-install.yaml)Standard (standard-install.yaml)
StatusImplementation-specific, no conformance gateCore conformance: GATEWAY-TCP / GATEWAY-UDP required to pass
v1alpha2Sole versionDeprecated, will be removed in a future release
Installkubectl apply -f .../experimental-install.yamlkubectl 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.yaml at v1.6.1 is now sufficient. Clusters that already run the experimental bundle keep working — v1alpha2 is 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 TCPRoute and UDPRoute tests, which was not true when they lived in Experimental.
  • The shape didn't change. TCPRoute is still deliberately thin: match by port on a Gateway listener, forward to a backend Service. No host, path, or header matching — that's HTTPRoute's job. The graduation is about stability and portability, not new fields.

Install check:

bash
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 v1alpha2

If 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:

yaml
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:

yaml
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: 5432

MQTT, same pattern, different listener:

yaml
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: 1883

And the HTTP app that was already there keeps working unchanged:

yaml
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: 8080

Three observations that matter for a platform:

  1. No NodePort in sight. The tenant never learns a high port. postgres.example.com:5432 is the wire address — standard port, standard DNS.
  2. 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.
  3. Namespace isolation is built in. allowedRoutes plus ReferenceGrant (when a route in one namespace targets a backend in another) is the RBAC for routing. The Gateway owner decides which tenants may attach to postgres: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:

yaml
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: Service

Which 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.

ImplementationStandard-channel TCPRoute v1Notes for a self-hosted fleet
Envoy GatewayYes — reference implementationFirst to pass Core conformance for GATEWAY-TCP; start here if you have no existing controller.
CiliumYes (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 / kgatewayYesChoose if you already run ambient mesh; otherwise heavier than needed for pure L4 routing.
TraefikYes from v3.1+ (enable kubernetesGateway provider)Requires Standard CRDs at v1.6.1; experimental CRDs alone will prevent the provider from starting.
NGINX Gateway FabricYesNatural fit if migrating from ingress-nginx (archived March 2026); similar declarative model.
HAProxy IngressTracking issue open (Aug 2026) — experimental onlyAn upstream issue explicitly asks for Standard-channel TCPRoute; not yet GA.
Kong / ContourPartial / ExtendedCheck 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:

bash
# 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-UDP

The 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: TLS protocol on the listener, Gateway holds the cert, traffic to the backend is plaintext. Useful when the Gateway is the trust boundary.
  • Passthrough: TLS or TCP protocol with tls.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:

bash
grep -R "gateway.networking.k8s.io/v1alpha2" --include="*.yaml" .
# replace with gateway.networking.k8s.io/v1 for TCPRoute/UDPRoute

What 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 TCPRoute objects, 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

  1. Upgrade CRDskubectl apply -f .../standard-install.yaml at v1.6.1. Confirm tcproutes.gateway.networking.k8s.io serves v1.
  2. Pick a GatewayClass — Envoy Gateway for portability, Cilium if it already owns your CNI. Run GATEWAY-TCP conformance.
  3. Add TCP listeners — one per standard port you need (5432, 1883, etc.). Scope allowedRoutes to labeled tenant namespaces.
  4. Replace one service — rewrite one tenant's NodePort/LoadBalancer Postgres Service to ClusterIP and put a TCPRoute in front of it. Test psql -h gateway.example.com -p 5432.
  5. Automate ReferenceGrants — if backends live outside tenant namespaces, have your platform controller mint the grants.
  6. Retire the old path — delete the NodePort Service, reclaim the LoadBalancer IP, close the firewall pinhole. grep for v1alpha2 and type: NodePort across the repo.
  7. 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.

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