GRPCRoute¶
GRPCRoute enables routing gRPC traffic through Cloudflare Tunnel with service and method-level matching. It is served by the in-process L7 proxy: gRPC requests are HTTP/2 POSTs to /{service}/{method}, which the proxy matches with its path matcher. The upstream hop to the backend is cleartext h2c by default; attaching a BackendTLSPolicy upgrades the hop to TLS with HTTP/2 negotiated via ALPN (see Backend TLS below).
gRPC needs the http2 tunnel transport — auto upgrades for you
cloudflared does not forward HTTP trailers over QUIC (its default transport), and gRPC carries the mandatory grpc-status in a trailer. Over a QUIC tunnel the trailer is dropped at the edge and every gRPC call fails with server closed the stream without sending trailers. This is a cloudflared/Cloudflare limitation, not a controller bug.
With the default proxy.tunnel.protocol: auto (or unset) you do not need to change anything: the proxy dials http2 at startup when a GRPCRoute is present. A GRPCRoute added after the proxy has already dialed a non-http2 transport needs a proxy restart (the proxy logs a restart-needed error). An explicit proxy.tunnel.protocol: quic is never upgraded and cannot serve gRPC — the controller logs an error and sets the GRPCRoute's Accepted condition to False / UnsupportedProtocol with an actionable message.
Prerequisite: enable gRPC on the Cloudflare zone
Separate from the tunnel transport, the Cloudflare zone must have gRPC proxying enabled (dashboard → Network → gRPC). If it is disabled, the Cloudflare edge returns 403 with content-type: text/html zone-wide for every application/grpc request — upstream of the tunnel, so the request never reaches the proxy. The GRPCRoute still reports Accepted=True while every gRPC call fails with the opaque client error rpc error: code = PermissionDenied ... received unexpected content-type "text/html". As a breadcrumb the controller emits a Normal Event (reason: GRPCEdgeProxyingRequired) on accepted GRPCRoutes naming this prerequisite. See Limitations.
Basic Example¶
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: my-grpc-service
namespace: default
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- grpc.example.com
rules:
- backendRefs:
- name: grpc-server
port: 50051
Service Routing¶
Route by gRPC service name:
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: grpc-service-routes
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- api.example.com
rules:
# Route UserService to user backend
- matches:
- method:
service: mypackage.UserService
backendRefs:
- name: user-service
port: 50051
# Route OrderService to order backend
- matches:
- method:
service: mypackage.OrderService
backendRefs:
- name: order-service
port: 50051
Method Routing¶
Route specific gRPC methods to different backends:
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: grpc-method-routes
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- api.example.com
rules:
# Exact method match - read operations
- matches:
- method:
service: mypackage.UserService
method: GetUser
backendRefs:
- name: user-read-service
port: 50051
# All other methods on the service - write operations
- matches:
- method:
service: mypackage.UserService
backendRefs:
- name: user-write-service
port: 50051
Method Matching¶
gRPC methods are mapped to HTTP/2 paths using the standard format /package.Service/Method.
| Match Type | Example | Proxy path rule |
|---|---|---|
| Service only | service: mypackage.MyService | prefix /mypackage.MyService/ |
| Service + Method | service: mypackage.MyService, method: GetUser | exact /mypackage.MyService/GetUser |
| Method only | method: GetUser | regex /[^/]+/GetUser (any service) |
| No match | (empty) | Matches all gRPC traffic |
An empty match ({}) also matches all traffic, even next to specific matches in the same rule: the rule then serves every request on its hostnames that a more specific match, in this route or another, does not take. Against an equally specific match, such as another route's rule without matches, the oldest route wins.
Match Type Field¶
The type field specifies how to match:
| Type | Description |
|---|---|
Exact | Exact string match (default) |
RegularExpression | Regular expression match |
A pattern that does not compile is handled the same way as on an HTTPRoute: the controller drops the rule and reports it on the route, leaving other routes on the data plane untouched. See HTTPRoute for what the route status carries.
Cross-Namespace Routing¶
GRPCRoute also supports cross-namespace backend references with ReferenceGrant:
---
# ReferenceGrant in target namespace
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-grpc-routes
namespace: grpc-services
spec:
from:
- group: gateway.networking.k8s.io
kind: GRPCRoute
namespace: app-namespace
to:
- group: ""
kind: Service
---
# GRPCRoute in source namespace
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: cross-ns-grpc-route
namespace: app-namespace
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- grpc.example.com
rules:
- matches:
- method:
service: mypackage.UserService
backendRefs:
- name: user-grpc-service
namespace: grpc-services
port: 50051
Backend Selection with Weights¶
When multiple backends are specified, traffic is split across them in proportion to their weights (a backend with weight 0 receives none):
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: weighted-grpc
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- grpc.example.com
rules:
- matches:
- method:
service: mypackage.UserService
backendRefs:
- name: primary-grpc
port: 50051
weight: 80 # ~80% of traffic
- name: fallback-grpc
port: 50051
weight: 20 # ~20% of traffic
Multiple Hostnames¶
Route multiple gRPC endpoints:
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: multi-host-grpc
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- grpc.example.com
- api.example.com
- "*.grpc.example.com"
rules:
- backendRefs:
- name: grpc-server
port: 50051
Checking Route Status¶
Verify that the route is accepted:
Status Conditions¶
| Condition | Meaning |
|---|---|
Accepted: True | Route is accepted and programmed in the in-process proxy; the tunnel ingress document may lag behind, see Limitations |
Accepted: False | Route was rejected (check reason) |
ResolvedRefs: True | All backend references resolved |
ResolvedRefs: False | Backend reference failed |
Limitations¶
Supported and not supported¶
| Feature | Status |
|---|---|
| Service / method matching (Exact, RegularExpression) | Supported |
| Header matching (Exact, RegularExpression) | Supported |
Filters (RequestHeaderModifier, ResponseHeaderModifier) | Supported — served via the shared header-modifier pipeline; of set or add entries whose names differ only in case, the first is used |
Filters (RequestMirror, ExtensionRef) | Not served — fail closed (matching requests receive gRPC status UNAVAILABLE) |
| Backend filters | Same as rule filters: header modifiers served, others fail closed |
BackendTLSPolicy / Gateway clientCertificateRef | Supported — see Backend TLS below |
Backend TLS¶
The default hop to a gRPC backend is cleartext h2c (HTTP/2 without TLS). When a BackendTLSPolicy targets the backend Service, the proxy upgrades the hop to TLS instead — BackendTLSConfig (CA bundle, ServerName, SANs) is stamped onto the backend, the URL is rewritten to https://, and HTTP/2 is auto-negotiated over the TLS handshake via ALPN. The Gateway's spec.tls.backend.clientCertificateRef is presented during the handshake the same way as for HTTPRoute, enabling mTLS.
Behavior matrix:
| State | URL scheme | Protocol | TLS |
|---|---|---|---|
No BackendTLSPolicy, no clientCertificateRef | http:// | h2c | none |
BackendTLSPolicy targets the Service | https:// | h2 (ALPN) | server-auth + optional mTLS |
Plus clientCertificateRef on the parent Gateway | https:// | h2 (ALPN) | mTLS |
A gRPC backend on port 443 follows the same rule — if a policy targets it, TLS; otherwise h2c. The buildServiceURL helper emits https:// for port 443 (the HTTPRoute convention); the gRPC converter undoes that to http:// for the cleartext-h2c path because h2c is HTTP/2 without TLS and needs an http:// URL.
Traffic Splitting¶
Weighted traffic splitting is supported: when a rule lists multiple backends with weights, the in-process L7 proxy distributes requests across them in proportion to their weights at request time (the same weighted selection used for HTTPRoute). A backend with weight 0 receives no traffic, and a rule whose backends all have weight 0 sends no traffic.
Note that gRPC connections are long-lived (HTTP/2 multiplexes many calls over one connection), so the split applies per request the proxy routes, not per TCP connection — a single client holding one connection still has its individual calls distributed by weight.
Troubleshooting¶
Route Not Accepted¶
Check controller logs:
kubectl logs --selector app.kubernetes.io/name=cloudflare-tunnel-gateway-controller \
--namespace cloudflare-tunnel-system
Common causes:
- Gateway not found
- Cloudflare API error
- Invalid method specification
gRPC Connection Issues¶
- Verify the proxy pods (which embed the cloudflared tunnel in-process) are running and connected:
- Check that the backend service supports gRPC (HTTP/2):
- Verify the backend pod is listening on the correct port: