Skip to content

Limitations

This document describes the known limitations of the Cloudflare Tunnel Gateway Controller and provides workarounds where applicable.

Controller Limitations

Limitation Description
Full sync Any change triggers full config sync
Backend kinds Service (ClusterIP, NodePort, LoadBalancer, ExternalName, and headless clusterIP: None — expanded to one backend per ready endpoint), ServiceImport (multicluster.x-k8s.io, resolved via clusterset.local DNS), and ExternalBackend (cf.k8s.lex.la, an out-of-cluster HTTP(S) URL). Any other kind → ResolvedRefs=False, InvalidKind.

Unsupported config is surfaced on resource status

When the controller will not serve a piece of route config exactly as written, it surfaces the decision on the resource's status conditions — not only in a log line — so kubectl describe httproute / grpcroute shows the problem and the fix. Several shapes are used, chosen per the Gateway API spec:

  • Accepted=False / UnsupportedValue — the whole route cannot be served. This fires when every rule of the route is unservable, e.g. each rule carries an unsupported filter type. When every rule is refused for combining RequestRedirect with URLRewrite, the reason is IncompatibleFilters instead.
  • Accepted=False / Pending — the route bound to its parent, but the parent could not be programmed as written. The causes do not clear the same way: a Gateway refused a tunnel it does not own stays this way until the claim is fixed, and says so on its own Accepted condition (InvalidParameters); so does a Gateway whose tunnel claim Cloudflare did not confirm, while one whose claim could not be checked yet because the Cloudflare API was unreachable is retried and clears once the API answers; a dedicated data plane that failed to resolve is equally stuck when the failure is a deterministic spec error, but a transient resolve failure is retried and recovers on a later sync without ever touching the Gateway's status. A parent the controller could not evaluate at all is reported the same way when the controller can read that Gateway for status; the controller log names the error, and the sync is retried until the parent can be evaluated. If that Gateway can never be read, for example because the read is permanently Forbidden, the route's previous status entry for that parent stays as it was, possibly Accepted=True, and the sync keeps retrying every 15 seconds until the read works. Reporting Pending with Accepted=False is a documented deviation from the spec's reason table, which lists it under Accepted=Unknown for a route the controller has not reconciled yet — recorded as SH-78 in the spec compliance matrix.
  • PartiallyInvalid=True (alongside Accepted=True) — some rules or backends are dropped while the route still serves the rest. The message starts with the spec-mandated Dropped Rule prefix and names the affected rule indices. This covers a single unsupported filter among several rules, an unsupported per-backend filter (only that backend's traffic fraction fails closed), an unparseable rule timeouts value (the rule serves without it), a rule combining RequestRedirect with URLRewrite, and a match pattern that does not compile (the rule is dropped, since a match that never compiles can serve nothing).
  • ResolvedRefs=False — a backend or object reference cannot be resolved or declares an unsupported app protocol. This covers a missing Service, a missing ServiceImport (or one that does not export the requested port), a missing ExternalBackend, a backend kind outside the supported set (Reason=InvalidKind), an unauthorized cross-namespace backendRef (Reason=RefNotPermitted), plus appProtocol: https/wss without a BackendTLSPolicy (Reason=UnsupportedProtocol), a BackendTLSPolicy the proxy cannot enforce (Reason=InvalidBackendTLSPolicy, see Fail-closed enforcement), an unrecognised appProtocol, and a dropped RequestMirror backendRef. A cross-namespace reference whose ReferenceGrants cannot be read is none of these: the route keeps its previous ResolvedRefs condition, the data plane serving the route keeps the config it has and receives no config change until the grants can be read, the controller log names the error, and the sync is retried.
  • Dedicated cf.k8s.lex.la/ condition (alongside Accepted=True) — a problem that leaves the route valid and bound but unserved, shadowed, or un-isolated, where the Gateway API defines no condition. cf.k8s.lex.la/RouteShadowed=True marks a route whose (hostname, match) pair is exactly claimed by a higher-precedence route; cf.k8s.lex.la/ProxyConfigPushed=False marks a SUSTAINED proxy config-push failure (the spec is valid, but the in-cluster proxy never received the config, so requests 502 — it clears on the first successful push; the same condition with reason ParentNotEvaluated marks a route that may be missing hostnames because a parent could not be evaluated: it serves only the hostnames its other parents lend, or is left out of the proxy config when none lends one; it clears once that parent can be evaluated); cf.k8s.lex.la/TunnelShared=True marks a per-Gateway data plane sharing one Cloudflare Tunnel with ANOTHER DEDICATED Gateway. Cross-namespace sharing is refused by default, so this condition appears only where the operator opted in with allowSharedTunnels on the GatewayClassConfig, or where the sharing Gateways live in one namespace; its reason, TunnelSharedAcrossNamespaces or TunnelSharedWithinNamespace, says which. Sharing the class tunnel is not flagged — the detector counts dedicated partitions, and the shared plane is not one. On a route with several parents, each of these conditions is written only under the status.parents[] entries whose Gateway is served by the data plane it describes. Each mirrors a Warning Event. The same domain-prefixed scheme is used for a listener advisory: cf.k8s.lex.la/PermissiveHostname=True flags a Gateway listener OR a ListenerSet entry combining allowedRoutes.namespaces.from: All with no hostname pin (the hostname-capture combination); the listener stays Accepted and the condition clears once it is pinned or scoped.
  • Kubernetes Event (reason=ConfigOverridden) — config applied successfully but a redundant or conflicting hint was overridden, where no standard condition fits. A Normal event covers a cleartext appProtocol (h2c, ws) superseded by a BackendTLSPolicy ("TLS wins"); a Warning event covers a ResponseHeaderModifier that strips a WebSocket handshake header (honored as written, but it breaks the upgrade) and a retry attempts above the proxy's limit of 10 (served with 10, see Retries).

Separately, Gateway and ListenerSet listeners whose protocol this controller cannot serve (TCP, TLS, UDP, or any unrecognised protocol) are marked Accepted=False, Reason=UnsupportedProtocol on the listener status. The Gateway reflects that on its own Accepted condition per the Gateway API spec: a Gateway holding any such listener alongside at least one servable one stays Accepted=True but with Reason=ListenersNotValid, and a Gateway whose listeners are all unservable is Accepted=False, Reason=ListenersNotValid. A ListenerSet reports its own entries the same way on its Accepted condition.

Unsupported filters fail closed: per the Gateway API spec an ExtensionRef, ExternalAuth, or unknown HTTPRoute filter type — and a GRPCRoute RequestMirror or ExtensionRef filter — must not be silently skipped. Requests matching the affected rule (or backend) receive HTTP 500, or gRPC status UNAVAILABLE for a gRPC request, rather than being served without the dropped filter. A rule-level filter takes the whole rule down; a per-backend filter (HTTPRoute or GRPCRoute backendRef.filters) fails only that backend's traffic fraction while the rule keeps serving its other backends. A RequestRedirect and a URLRewrite on the same rule are refused the same way, with HTTP 500 (gRPC: UNAVAILABLE) for the whole rule, wherever they sit: the CRD's validation rejects the pair inside one filter list, but checks the rule's list and each backendRef's list separately, so a rule-level URLRewrite next to a backendRef-level RequestRedirect, or the two on sibling backendRefs, are admitted and refused by the controller. The GRPCRoute core RequestHeaderModifier and the extended ResponseHeaderModifier filters are served — gRPC metadata is carried as HTTP/2 headers, so they route through the same header-modifier pipeline as HTTPRoute.

A RequestMirror filter is best-effort by specification, and the proxy keeps at most 64 mirror dispatches in flight per filter instance by default (proxy.mirror.maxInFlight changes it on the shared proxy and on per-Gateway data planes). Past that the copy is dropped rather than queued, because each in-flight dispatch pins its buffered request body — up to 1 MiB, above which the request is not mirrored at all — and a mirror backend that stops answering would otherwise let that memory grow with request rate. At the default, budget around 64 MiB of proxy memory for each mirror filter a config carries; a burst of config pushes can exceed that briefly, since every push builds fresh filters while the previous generation's dispatches hold their bodies until they time out. The chart ships proxy.resources.limits.memory: 512Mi, so one mirror filter at its ceiling accounts for roughly an eighth of it, and a config carrying several of them wants that limit raised. Every drop is counted by cftunnel_proxy_mirror_dropped_total. Drops are also logged at each power of ten per filter instance, so a mirror backend that is down does not write a line per dropped request; that log count restarts with each new generation of filters, so the metric is the signal to alert on. The primary request is never affected.

The mirror copy leaves out the client's hop-by-hop headers (Connection, Upgrade and the rest of the RFC 7230 set, plus any header Connection names), keeping TE: trailers. The forwarding headers (Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto) are kept even when Connection names them, and the connection's peer address is appended to X-Forwarded-For, both as on the primary path; see Forwarded Headers Reaching Backends. A mirrored WebSocket upgrade request therefore reaches the mirror backend as a plain request, the same way the primary path forwards an upgrade to a backend without WebSocket enabled.

A TLS appProtocol (https, kubernetes.io/wss) without a BackendTLSPolicy fails the backend closed (HTTP 500, or UNAVAILABLE for a gRPC request) rather than dialing plaintext to a TLS backend. An unrecognised appProtocol is report-only: the proxy keeps serving over HTTP/1.1 and records the diagnostic so the ignored hint is visible.

Non-Service backend kinds

Because the data plane is a generic in-process L7 proxy that ultimately dials a URL, a backendRef may target more than a core Service:

  • ServiceImport (multicluster.x-k8s.io) — a multicluster Service imported by the Multi-Cluster Services (MCS) API. The controller reads spec.ports to validate the requested port and resolves the backend to <name>.<namespace>.svc.clusterset.local:<port>, letting the cluster's MCS DNS plane route to the imported endpoints. An absent ServiceImport, or one that does not export the requested port, is surfaced as ResolvedRefs=False, BackendNotFound. The cluster must run an MCS implementation that serves clusterset.local; the zero-ready-endpoint 503 probe (applied to core Services) is not applied to ServiceImport, since MCS EndpointSlice labelling is implementation-specific. Unlike ExternalBackend, a ServiceImport is not watched (its CRD is not guaranteed installed, and a hard watch would crash the controller on clusters without MCS): editing a ServiceImport — e.g. adding the port a route requested — does not auto-retrigger a reconcile, so the referencing route clears BackendNotFound on its next reconcile (a route or Service change, or a controller restart) rather than immediately.
  • ExternalBackend (cf.k8s.lex.la) — a namespaced CRD declaring an out-of-cluster HTTP(S) origin (spec.scheme / spec.host / spec.port / optional spec.path). The proxy dials that URL directly from the pod. The backendRef port is ignored in favour of spec.port. A missing ExternalBackend is surfaced as ResolvedRefs=False, BackendNotFound and returns HTTP 500 (gRPC: UNAVAILABLE) for its traffic fraction. There is no SSRF allowlist: an ExternalBackend is operator-authored and the trust boundary is namespace write-access plus ReferenceGrant, identical to a Service of type ExternalName — supporting which is itself a deliberate deviation from the Gateway API recommendation that implementations SHOULD NOT support ExternalName Services (CVE-2021-25740), accepted on that same trust-boundary basis. gRPC over an ExternalBackend uses h2c when scheme: http and HTTP/2 over TLS (ALPN) when scheme: https.

A cross-namespace ServiceImport or ExternalBackend backendRef requires a ReferenceGrant whose to entry names the matching group/kind (multicluster.x-k8s.io/ServiceImport or cf.k8s.lex.la/ExternalBackend) — a Service-only grant does not authorize them.

The core group spelling is tolerated in some references and not others

Gateway API gives the Kubernetes core API group exactly one spelling: the empty string. Write group: "" in a ReferenceGrant and in the reference it authorises, and none of the following matters.

This implementation also accepts the non-canonical core in several places. That predates the current code and stays for compatibility, but the spec does not sanction it and it is not applied everywhere, so a spelling carried from one working manifest to another can be refused with no obvious reason:

Reference group: core
A backendRef to a Service Accepted
The ReferenceGrant to entry authorising that Service Accepted
BackendTLSPolicy.spec.validation.caCertificateRefs to a ConfigMap Accepted
Gateway.spec.tls.backend.clientCertificateRef to a Secret Rejected
A Gateway or ListenerSet listener tls.certificateRefs entry to a Secret Rejected
The ReferenceGrant to entry authorising any of those Secrets Rejected

The asymmetry is deliberate. Accepting core on backend references is kept only so manifests that already rely on it keep working; Secret references never accepted it, so there is nothing to keep compatible there, and they follow the spec.

Both halves of the Secret case reject it, and they fail in different places, so either one alone is enough to break a working manifest. A clientCertificateRef spelled core is not recognised as a core Secret reference at all, so the Gateway reports ResolvedRefs=False; across namespaces the reason is RefNotPermitted unless a grant also spells the group core. A grant spelled core — the shape you get by copying a working Service grant and changing kind — matches nothing, so a canonically spelled reference across namespaces is still refused, as RefNotPermitted. A listener certificateRefs entry spelled core fails on the listener instead: it reports ResolvedRefs=False, with reason RefNotPermitted across namespaces unless a grant also spells the group core, and InvalidCertificateRef otherwise.

Both are fail-closed: the backend hop proceeds without a client certificate rather than with someone else's, and nothing is served that should not be. But if you arrived here because a certificate reference stopped working after you carried over a spelling that works for backendRef, one of those two is why.

Use group: "" everywhere and this asymmetry cannot bite.

Traffic Splitting and Load Balancing

The in-process L7 proxy performs weighted traffic splitting across the backendRefs of a rule, for both HTTPRoute and GRPCRoute. Each backend's weight is honoured by a weighted-random selection at request time: traffic is distributed in proportion to the weights, and a backend with weight: 0 receives no traffic (a rule whose backends all have weight 0 serves nothing). Weighted splitting works directly in the proxy and does not require an external load balancer.

A headless Service (spec.clusterIP: None) has no VIP for kube-proxy to balance, so the controller resolves its EndpointSlices and expands the backendRef into one backend per ready endpoint, dialing each pod at its targetPort. The endpoints share the backendRef's weight equally, so a headless Service is load-balanced per-endpoint by the proxy's own weighted-random selection while preserving the backendRef's overall traffic proportion against any siblings. A normal Service with a ClusterIP is left to route through its VIP and kube-proxy.

For plain round-robin between pods of one Deployment, a standard Kubernetes Service is still the simplest option — point the route at the Service and let kube-proxy balance:

apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  selector:
    app: my-app
  ports:
    - port: 80
      targetPort: 8080

This keeps the controller simple and predictable, and gives you full control over load balancing behavior.

Unavailable backends return a status, not a dial error

When a rule's backendRefs include a backend that cannot serve traffic, the proxy answers that backend's traffic fraction with an error status rather than dialing a dead address (which would surface as a generic 502). The backend stays in the weighted pool, so the fraction is preserved and the other backends keep serving their share, as the Gateway API spec requires.

A gRPC request (Content-Type: application/grpc) gets gRPC status UNAVAILABLE in place of either HTTP status below, as GRPCRoute requires. The choice follows the request's content type, not the route kind, so a gRPC request routed by an HTTPRoute gets UNAVAILABLE too, although HTTPRoute asks for the HTTP status: a gRPC client cannot read an HTTP error status and reports it as Unknown. The status travels in a trailer, so like every gRPC response it needs the http2 tunnel transport. The same goes for a backend the proxy dials but gets no response from: a plain HTTP request gets 502, or 504 on a timeout, and a gRPC request gets UNAVAILABLE for both, following the gRPC mapping of those HTTP statuses. A proxy-side timeout is not the client's own deadline, so it is not reported as DEADLINE_EXCEEDED. The HTTP status of such a response is 200, and the access-log status field shows 200. It is still counted as 5xx in cftunnel_proxy_requests_total, logged regardless of sampling and marked as an error on the trace span, as the 5xx it replaces would be. A gRPC error status sent by a backend gets none of this.

Two cases are handled:

  • Invalid backendRef → 500. The backendRef is invalid for any reason the spec recognises: it names a Service that does not exist, a cross-namespace Service with no permitting ReferenceGrant, a non-Service kind, or an out-of-range port. As long as the ref carries traffic (weight greater than 0) it stays in the weighted pool and requests routed to it receive 500 for its fraction. A weight: 0 invalid ref carries no traffic, so it is dropped rather than marked (no fraction is lost). The marking belongs to the route that made the reference: another route that a ReferenceGrant allows to use the same Service keeps serving it.
  • Service with no ready endpoints → 503. The Service exists (and is authorized) but currently has zero ready endpoints (for example, all pods are NotReady during a rollout or a scale-to-zero). Requests routed to this backend receive 503. An ExternalName Service is never treated this way — it has no EndpointSlices yet is legitimately reachable. As pods become Ready/NotReady the controller re-evaluates the marking (it watches EndpointSlices), so the 503 clears automatically once an endpoint is ready.

If a backend is both nonexistent and endpoint-less the 500 (invalid-ref) status wins. A single-backend rule whose only backend is unavailable returns the corresponding status for all of its traffic.

Multi-Tenant Isolation Boundary

By default all Gateways of the class share one proxy process and one Cloudflare Tunnel; route-level scoping is enforced at two depths: allowedRoutes and the optional hostname-ownership policy gate admission (control plane), while route-listener hostname intersection is also enforced in the data plane — the proxy narrows each route to the hostnames its bound listeners cover, so a Host outside every listener's scope returns 404. The Gateway API defines no route-to-route hostname ownership — same-hostname routes merge by precedence, and a shadowed route is reported via the cf.k8s.lex.la/RouteShadowed condition rather than rejected. That condition is exact-match only: it does not flag overlapping-but-non-identical wildcards (*.example.com versus *.app.example.com), so cross-tenant wildcard overlap stays invisible to it — use the hostname-ownership policy to bound a tenant's wildcards rather than relying on collision detection. Per-ListenerSet TLS certificate refs are validated for status, never served (TLS terminates at the Cloudflare edge). For hard isolation a Gateway can opt into a dedicated proxy + tunnel. See the Multi-Tenancy and Per-Gateway Isolation guides.

A consequence of the shared tunnel: two Gateways of the same class share one edge address (the tunnel CNAME), so a route bound to one Gateway is reachable through the other's address for any hostname+path they both answer. Routing is by hostname and path, not by which Gateway the request nominally targeted — there is no per-Gateway signal on the wire. If you need a route reachable through only one Gateway's address, give that Gateway a dedicated data plane (its own tunnel) via spec.infrastructure.parametersRef. This is why the Gateway API conformance test HTTPRouteMultipleGateways is not run against the shared plane.

Routes attached to another implementation's Gateway

A route may carry parentRefs to this controller's Gateway and to one owned by another Gateway API implementation at the same time, which is the normal shape of a migration. A Gateway whose GatewayClass.spec.controllerName names a different controller contributes nothing to what this data plane serves: its listeners lend neither their hostname to the route's served set nor their protocol to a scheme-less RequestRedirect. The same applies to a ListenerSet, judged by its parent Gateway. A Gateway whose class is missing or cannot be read at that moment is not treated as foreign, so a route bound to this controller's own Gateway keeps inheriting its listener's hostname. A parent that cannot be evaluated at that moment lends no hostname either: its Gateway, its ListenerSet or the ListenerSet's parent Gateway cannot be read, or the ListenerSet's namespace cannot be read for the parent Gateway's allowedListeners selector. Likewise a listener that admits routes by namespace selector lends no hostname while the route's namespace cannot be read, though the parent's other listeners still lend theirs. A route that another parent or listener lends hostnames to serves only those, and a route that no other parent or listener lends a hostname to is left out of the proxy configuration, instead of being served with the hostnames it declares, or under every Host when it declares none. The route is marked cf.k8s.lex.la/ProxyConfigPushed=False with reason ParentNotEvaluated, and the controller retries the sync until the parent can be evaluated, unless the route declares hostnames and its other parents or listeners already cover all of them.

The tunnel ingress document only feeds the dashboard

The controller writes each tunnel an ingress document through the Cloudflare API, and that document does not decide where requests go. The edge sends a request to the tunnel by the DNS record of its hostname, and the connector hands every request to the in-cluster proxy, which does all routing. A hostname is reachable once its DNS record points at the tunnel, whether the document lists it or not. The document is what the Cloudflare dashboard shows for the tunnel.

The document carries one rule per distinct hostname, without a path, since the proxy does all path, method, header and query matching itself. A hostname served by several rules, routes or route kinds, from one namespace or from several, is still one rule. That rule names the lexicographically smallest of the backend URLs serving the hostname. This is the backend the dashboard shows for it, and it has no effect on where requests go. A route that declares no hostnames adds no rule. The document has no size cap.

The controller remembers the last document it read or wrote for each tunnel. A sync whose document matches it makes no API call; the document is read back once the remembered copy is 5 minutes old, on the next sync after that. An edit made outside the controller, for example in the dashboard, is therefore reverted by the next sync that changes the tunnel's document, and while the routes do not change, by the first sync at least 5 minutes after the controller last read or wrote that document. The calls this saves are counted in cftunnel_cloudflare_api_calls_skipped_total.

A failed write, such as an unreachable Cloudflare API, a refused credential or a deleted tunnel, changes nothing for traffic or for route and Gateway status. Routes keep their Accepted condition and keep serving. The controller counts every failure in cftunnel_sync_errors_total, emits a Warning Event with reason TunnelDocumentWriteFailed on every Gateway served from that tunnel, logs the error once for as long as it keeps failing the same way, and retries the write every 15 seconds. A wrong or revoked API token for the class tunnel therefore shows up only there: in those Events, that metric and the controller log. While the routes do not change, the controller makes no API call for a tunnel's document for up to 5 minutes after it last read or wrote a tunnel's document, so a token revoked or a tunnel deleted in that window shows up only once that time has passed. A dedicated Gateway's tunnel claim is still checked through the Cloudflare API, see below.

A dedicated Gateway waits for Cloudflare to confirm its tunnel

A Gateway with its own data plane is accepted only after the Cloudflare API confirms that its connector token holds the tunnel it names: the controller fetches that tunnel's token with the API credential that will write the tunnel's configuration, and compares the secrets. A token with an edited tunnel UUID, or one for a tunnel whose secret has since been rotated, is refused with Accepted=False / InvalidParameters, and no data plane is rendered. An API credential that Cloudflare rejects counts as an outage, not a refusal.

This ties dedicated planes to the Cloudflare API's availability. While the API cannot be reached, a Gateway created in that window stays Accepted=False and is retried until the check can run. A Gateway already serving its tunnel is not affected: it keeps the tunnel for the length of the outage, unless another namespace's claim on the same tunnel is confirmed. The Per-Gateway Isolation guide covers the cache and what the outage fallback trusts.

Informational (1xx) responses are not forwarded

A backend's informational response, such as 103 Early Hints, does not reach the client through the tunnel. The proxy drops it and sends only the final status and headers. Over QUIC the connector sends exactly one response head, so a forwarded 1xx would replace the real response; over HTTP/2 the connector records the response as started on the first status it is given. The final response is unaffected; the client only loses the hint. Outside the tunnel, on a rule with retry, a 1xx from an attempt that may still be retried is held until that attempt's final response, and dropped if the attempt is retried.

SSL Certificate Limitations

Cloudflare's free Universal SSL certificates only cover root and first-level subdomains:

Hostname Covered Notes
example.com Yes Root domain
*.example.com Yes First-level wildcard
app.example.com Yes First-level subdomain
app.dev.example.com No Multi-level subdomain
*.dev.example.com No Multi-level wildcard

Workaround

For multi-level subdomains, you need:

Client certificate validation (spec.tls.frontend) is refused

Clients complete TLS with the Cloudflare edge, and the tunnel does not carry their certificate to the proxy, so this controller cannot validate client certificates. A Gateway that sets spec.tls.frontend, with any content (default or per-port validation, any caCertificateRefs and mode, or an empty block), is refused:

  • The Gateway reports Accepted=False and Programmed=False with Reason=Invalid, and its listeners report Programmed=False. A Gateway on the shared plane clears status.addresses; a dedicated Gateway keeps its address (see below).
  • Routes attached to it, directly or through a ListenerSet, report Accepted=False with Reason=NoMatchingParent for that parent and are not served through it.
  • Its ListenerSets report Accepted=False with Reason=ParentNotAccepted.
  • A dedicated data plane (spec.infrastructure.parametersRef) is removed, so no connector of it stays registered on the tunnel.

The refusal covers the whole Gateway, not only its HTTPS listeners: the edge accepts HTTPS for every hostname whatever the listener protocol, so no listener on the Gateway could honour the setting. Removing spec.tls.frontend restores the Gateway and its routes.

To require client certificates, enforce them at the Cloudflare edge before requests reach the tunnel, for example with Cloudflare's client-certificate (mTLS) or Access policies; check which of them your Cloudflare plan includes.

The upstream conformance tests for this feature (GatewayFrontendClientCertificateValidation, GatewayFrontendClientCertificateValidationInsecureFallback, GatewayFrontendInvalidDefaultClientCertificateValidation, GatewayInvalidFrontendClientCertificateValidation) are skipped: they test validation this controller does not claim to support.

Gateway Listener Configuration

Gateway listeners follow Gateway API specification. Some fields are ignored because Cloudflare Tunnel manages them at the edge:

Field Status Notes
port Ignored for routing Requests arrive on the ports the Cloudflare edge serves; the listener port only sets the redirect port of a RequestRedirect that leaves scheme empty, when the edge serves it (Redirect port)
protocol Validated Only HTTP and HTTPS listeners are served (they carry HTTPRoute / GRPCRoute). A TCP, TLS, or UDP listener has no data plane here and is marked Accepted=False, Reason=UnsupportedProtocol (and not Programmed) on its listener status
hostname Supported Routes must have intersecting hostnames; see Listener isolation
tls Validated certificateRefs are checked (a valid TLS Secret, with a ReferenceGrant for a cross-namespace ref) and reported on the listener status, but never served: clients get the Cloudflare edge certificate
allowedRoutes Supported Namespace (Same/All/Selector) and kind filtering

An HTTPS listener is accepted and serves the requests clients send to the edge over HTTPS. The client completes TLS with the Cloudflare edge and gets the edge certificate for the hostname (SSL Certificate Limitations), never one of the listener's certificateRefs; the tunnel carries the decrypted request to the in-cluster proxy, so no TLS is terminated in the cluster. The same Accepted=False, Reason=UnsupportedProtocol listener verdict applies to ListenerSet entries.

Certificate references of an unsupported kind

Only a core Secret is supported. A listener certificateRefs entry, or a clientCertificateRef, that names any other kind is refused with ResolvedRefs=False. Across namespaces the ReferenceGrant is checked first, and it must name that kind: with no such grant the reason is RefNotPermitted, otherwise InvalidCertificateRef (InvalidClientCertificateRef for the client certificate).

Programmed on the shared plane does not track the proxy

A Gateway on the shared data plane reports Programmed=True unless it is not accepted or its spec.addresses asks for another hostname (AddressNotUsable). A Gateway that is not accepted reports Programmed=False on either plane, with Reason=Invalid when every listener of its own is invalid (Accepted=False, Reason=ListenersNotValid). The exception is such a Gateway with an attached ListenerSet (counted in status.attachedListenerSets): the ListenerSet's listeners are served as the Gateway's, so it stays Programmed=True. The condition does not follow the readiness of the chart-managed proxy or its tunnel connection, so a Gateway can show Programmed=True while no proxy replica is ready. A Gateway with a dedicated data plane reports Programmed=True only once its proxy Deployment has a ready replica. For the shared plane's health, watch the ready replicas of the proxy Deployment the chart installs.

Listener isolation

A request belongs to the most specific listener of a Gateway whose hostname matches it, and only routes attached to that listener can answer it. With listeners *.example.com and foo.example.com, a request for foo.example.com is served only by routes attached to foo.example.com; a route attached to *.example.com answers bar.example.com but not foo.example.com, even when it lists foo.example.com in its own hostnames. A listener without a hostname gets only the hosts no other listener matches. An exact hostname is more specific than any wildcard, and a wildcard with more labels is more specific than one with fewer. ListenerSet entries count as listeners of their parent Gateway. A listener that is not Accepted admits no route and owns nothing: a conflicted listener, one with an unsupported protocol, and one whose namespace selector does not parse. A listener that is Accepted owns its hostname even while no route is attached to it, so those requests get a 404.

Listener ports are not compared. The tunnel does not tell the proxy which port or scheme a request arrived on, so isolation is computed across all listeners of a Gateway. Two listeners with the same hostname on different ports are equally specific, and routes on either one answer that hostname.

Isolation is per Gateway. A route attached to several Gateways on the same data plane answers a host when any of them gives the host to a listener the route is attached through.

spec.addresses accepts only the tunnel hostname

The only reachable address for a Cloudflare Tunnel is the tunnel CNAME (<tunnel-id>.cfargotunnel.com), which the controller assigns and reports in status.addresses. A Gateway cannot request any other address, the same constraint as the Gateway API GatewayStaticAddresses feature, which this implementation does not claim. The controller reports what it cannot serve:

  • A Hostname address equal to the tunnel CNAME, or with no value, is served as usual.
  • A Hostname address with any other value leaves the Gateway Accepted=True and serving at the tunnel CNAME, but Programmed=False with reason AddressNotUsable and a message naming both hostnames.
  • Any other address type (IPAddress, which is also the type an entry without one gets, NamedAddress or a custom type) makes the Gateway Accepted=False with reason UnsupportedAddress. Like any refused Gateway it serves no route: its routes report Accepted=False with reason NoMatchingParent, its ListenerSets report ParentNotAccepted, a Gateway on the shared plane clears status.addresses, and a dedicated data plane is removed.

Remove spec.addresses, or leave a single Hostname entry without a value, to let the controller assign the address.

status.addresses is not dialable in-cluster

Gateway.status.addresses reports the tunnel CNAME (<tunnel-id>.cfargotunnel.com), which resolves nowhere inside the cluster — the data plane is tunnel-only and exposes no in-cluster HTTP listener for routed traffic. Any tooling that expects to reach a Gateway's data plane by dialing its status address directly from inside the cluster (rather than through the Cloudflare edge) cannot do so. This breaks Knative Serving's net-gateway-api readiness prober specifically; see the Knative Serving guide for the mechanism and the supported split-horizon workaround.

A dedicated Gateway keeps its status.addresses through a configuration error

A Gateway with its own data plane retains its <tunnel-id>.cfargotunnel.com address while reporting Accepted=False, where a Gateway on the shared plane clears it. The address is what records which tunnel that plane holds, and tunnel ownership is decided by it — clearing it during an ordinary token rotation would surrender the tunnel to the next claimant.

The visible consequence is for external-dns: it does not withdraw the DNS record when such a Gateway's configuration breaks, so the record outlives the failure and is removed only when the Gateway is deleted or opts out of its dedicated plane.

A Gateway refused by the tunnel-ownership rule reaches the same state, and there it is not a passing failure: the refusal stands until the contested tunnel is released, so the Gateway advertises — and external-dns publishes — a tunnel it will never serve. Deleting the Gateway or removing its spec.infrastructure.parametersRef withdraws the record.

Backend Protocol (Service.spec.ports[].appProtocol)

The L7 proxy reads the backend Service port's appProtocol to pick the upstream transport. The supported Kubernetes-defined values:

appProtocol Supported Notes
(unset) Yes Default — proxy speaks HTTP/1.1 to the backend
kubernetes.io/h2c Yes Proxy speaks HTTP/2 cleartext (prior knowledge)
kubernetes.io/ws Yes The proxy detects Connection: Upgrade + Upgrade: websocket headers and routes the request through a dedicated WebSocket upgrade path that dials the backend, forwards the upgrade, writes the 101 to the response writer, and bidirectionally copies bytes after hijack. Plain HTTP requests to the same backend continue to use the default HTTP/1.1 transport
kubernetes.io/wss Yes Requires a BackendTLSPolicy targeting the Service (same precondition as appProtocol: https); without one the proxy fails the backend closed (HTTP 500) and sets ResolvedRefs=False, Reason=UnsupportedProtocol on the route — dialing plaintext to a TLS backend would silently fail
any other value No Report-only: the proxy serves over HTTP/1.1 (a safe default the backend may speak) and sets ResolvedRefs=False, Reason=UnsupportedProtocol on the route so the ignored hint is visible

An upgrade request to a backend without kubernetes.io/ws or kubernetes.io/wss is forwarded as plain HTTP: the proxy does not pass on the Upgrade header or the upgrade token of Connection, so the backend receives an ordinary request and the client gets its ordinary response on either tunnel transport. If a backend switches protocols anyway, the client gets a 502.

The upstream conformance test HTTPRouteBackendProtocolWebSocket runs through the Cloudflare edge like the HTTP and gRPC suites: the conformance run supplies a tunnel-aware WebSocket dialer through the suite's injectable dialer option, rewriting the target to the edge hostname over wss and carrying the test's intended host in X-Original-Host (which the proxy honours only when the deployment sets proxy.allowXOriginalHost, as the conformance setup does) — the Gateway address itself (<tunnel-id>.cfargotunnel.com, AAAA records in Cloudflare's ULA fd10::/8) is unreachable from any test runner. test/e2e/e2e_backend_protocol_websocket_test.go additionally proves the WebSocket path end to end against the real tunnel hostname without the header pattern.

gRPC backends and appProtocol

The table above describes HTTPRoute backends. A GRPCRoute backend is HTTP/2 by definition, so it is dialed h2c (cleartext HTTP/2) by default and the only appProtocol decision that applies is TLS-vs-cleartext. That decision is honoured identically to the HTTP path: a Service port declaring a TLS appProtocol (https, HTTPS, or kubernetes.io/wss) with no BackendTLSPolicy targeting the Service fails the gRPC backend closed (gRPC status UNAVAILABLE) and sets ResolvedRefs=False, Reason=UnsupportedProtocol on the GRPCRoute — dialing cleartext h2c to a backend that asked for TLS would silently defeat the operator's intent. A BackendTLSPolicy puts TLS on the wire (ALPN negotiates HTTP/2); every other appProtocol value (unset, kubernetes.io/h2c, or an unrecognised string) keeps the h2c default — the correct gRPC transport regardless.

Interaction with appProtocol: kubernetes.io/ws

When a backend Service carries both appProtocol: kubernetes.io/ws AND a BackendTLSPolicy targeting the same Service, the TLS policy wins: resolveBackendTLS rewrites the URL to https://, the proxy completes a TLS handshake, and the WebSocket upgrade runs over TLS regardless of the cleartext appProtocol hint. The config is applied successfully, so this is surfaced as a Normal Kubernetes Event (reason ConfigOverridden) on the route — not a condition — so operators can either drop the BackendTLSPolicy (if they actually wanted cleartext WebSocket) or flip the hint to kubernetes.io/wss (if they wanted the TLS-protected variant all along). The same Normal event fires for appProtocol: kubernetes.io/h2c suppressed by a BackendTLSPolicy.

ResponseHeaderModifier MUST preserve WebSocket handshake headers

The L7 proxy applies route-level + per-backend ResponseHeaderModifier filters to every backend response, including the 101 Switching Protocols response that carries the WebSocket handshake. Per Gateway API spec the filter pipeline runs unconditionally; the proxy makes no exception for upgrade responses. The operator-facing consequence: a Remove list that strips Sec-WebSocket-Accept, Upgrade, or Connection on a route whose backend is WS-marked silently breaks every upgrade on that route. The 101 reaches the client missing a header the RFC 6455 handshake requires, and the client just disconnects.

The converter scans rule-level and per-backend ResponseHeaderModifier filters at HTTPRoute apply time. If a Remove list on a WS-marked route intersects {Sec-WebSocket-Accept, Upgrade, Connection}, the controller emits a Warning Kubernetes Event (reason ConfigOverridden) on the route naming the offending header(s) and the filter scope (rule or backend). The filter still applies as configured — the event is a diagnostic, not a hard rejection, because the misconfiguration is operator-fixable and bypassing the filter would silently violate spec.

Same guidance applies symmetrically to Set overriding these headers with a non-handshake-compatible value, though that is rarer and not currently checked.

Interaction with spec.rules[].timeouts

timeouts.request and timeouts.backendRequest are enforced as header-only deadlines on the backend transport (http.Transport.ResponseHeaderTimeout), not as full-request context deadlines. The deadline bounds only the wait for backend response headers; once headers arrive, the body streams freely. On a rule without retry, both Gateway API knobs collapse onto the same transport-level header timeout, since a single backend attempt is the whole request; when both are set the stricter (min(Request, Backend)) value wins. On a rule with retry, timeouts.backendRequest bounds each attempt and timeouts.request bounds all attempts together; see Retries.

This is a deliberate spec interpretation. The spec is underspecified on whether timeouts.request should kill an in-flight streaming response (Server-Sent Events, chunked transfer, large file downloads, gRPC server-streaming). A context-based deadline cancels the body read mid-stream and truncates the response at the timeout boundary, which is hostile to any streaming workload. The header-only deadline avoids that while still catching slow-to-respond backends in the dial-and-headers phase — exactly where timeouts are operationally useful.

Backends that take longer than the deadline to send response headers get a 504 Gateway Timeout (gRPC: UNAVAILABLE) to the client (the transport's ResponseHeaderTimeout error is mapped to 504 in errorHandler, parallel to the existing 504 for context.DeadlineExceeded).

Symmetric consequence on request uploads: per the stdlib godoc, ResponseHeaderTimeout starts measuring only after the request body is fully written. On a rule without retry, a streaming or very large request upload (chunked PUT, multipart, gRPC client-streaming) is therefore NOT bounded by timeouts.request either. Operators who expected timeouts.request to act as an upload budget should know that the deadline starts only when the upload completes and the wait for response headers begins. The transport's connect timeout (dial / TLS handshake) still bounds the establishment phase. A rule with retry differs: its request timeout does cover the upload, see Retries.

The same shift removes the slow-loris-upload protection the old context-based deadline incidentally provided: a malicious client that drip-feeds request body bytes will keep the proxy → backend conn open as long as it sends at least one byte before the underlying TCP read timeout. The Cloudflare edge in front of the tunnel has its own upload deadlines, so the operational risk in production is bounded by edge policy rather than by timeouts.request. Per-rule upload-phase deadlines are out of scope for this knob and would need a separate mechanism (e.g. a wrapping io.Reader with its own inter-byte deadline) if ever required.

WebSocket routes are naturally exempt: WS upgrades flow through the dedicated proxyWebSocketUpgrade path (because cloudflared's HTTP/2 response writer cannot be hijacked the way stdlib httputil.ReverseProxy expects), and that path bypasses the cached transport entirely. Once the upgrade completes, two io.Copy goroutines pipe bytes bidirectionally between the hijacked client conn and the backend conn. They run until either side closes its conn, or until the session carries no bytes in either direction for proxy.websocket.idleTimeout (1h by default) — a bound on abandonment, not on duration, since any traffic either way resets it. That bound covers the upgraded session only. When the backend refuses the upgrade, its non-101 response body is copied through with no deadline of any kind, so a backend that stalls part-way into that body still holds the goroutine and the tunnel stream open.

BackendProtocol: H2C backends go through the same http.Transport.ResponseHeaderTimeout, which net/http applies to HTTP/2 streams too, so everything above holds for h2c and gRPC backends as well, including the upload rule: a gRPC client-streaming call is not bounded by timeouts.request while the client is still sending. The h2c dialer's own connect timeout still catches a fully dead backend.

When a rule's timeouts value cannot be parsed (not a valid GEP-2257 duration), the rule is still served without the timeout and the route is marked PartiallyInvalid=True with a Dropped Rule message naming the rule, so the dropped timeout is visible on kubectl describe httproute rather than only in a log line.

Retries

spec.rules[].retry is an Experimental-channel field: it reaches the controller only when the cluster runs the Experimental Gateway API CRDs, since the Standard CRD prunes it. When set, the proxy retries a backend request:

  • on any status listed in codes (400–599, so 4xx codes are honoured too);
  • on any transport error, whatever codes lists: a refused or reset connection, or an attempt that reached timeouts.backendRequest.

attempts counts retries after the first request, so a backend sees at most attempts + 1 requests; unset, it is 1. When the retries run out, the client gets the last outcome: the backend's error response as it was sent, or 502 / 504 for a transport error (gRPC: UNAVAILABLE). backoff is the fixed wait between attempts, with no exponential growth or jitter; unset, it is 25ms. Every retry is counted in the proxy's cftunnel_proxy_backend_retries_total metric.

The proxy is shared by every route on its data plane, so one route must not be able to make it hammer a failing backend. Two limits apply whatever the route says. A request is retried at most 10 times: a rule with a larger attempts keeps serving with 10, and the controller emits a Warning Event (reason ConfigOverridden) on the route saying so. The route's conditions do not change, since retrying fewer times than attempts stays within the spec. Attempts are at least 10ms apart: a backoff of 0s, or below 10ms, waits 10ms. The spec words attempts as a maximum and backoff as a minimum, so retrying less and waiting longer both stay within it.

Every attempt goes to the backend chosen for the request, so in a weighted rule a retry does not move to another backend. Every method is retried, POST and other non-idempotent ones included. The spec does not limit retries by method, and retry is an opt-in per rule, so a rule whose backend cannot take a repeated request should not set it.

A request body is kept in memory, up to 64 KiB, so a retry can resend it. A body with a declared Content-Length of at most 64 KiB is read before the first attempt, so a client sending Expect: 100-continue gets 100 Continue from the proxy and the backend can no longer refuse the request before the upload. If such a body turns out longer or shorter than declared, the client gets 502 (gRPC: UNAVAILABLE) and no attempt is made, since every attempt would fail the same way. A body of unknown length (a chunked upload, a streaming gRPC call, or a request the edge forwards over HTTP/2 without closing the stream) streams to the backend as it arrives and is recorded on the way, so it can be resent only if the first attempt sent all of it within 64 KiB. A bodyless request forwarded that way counts as fully sent. Any other request is not resent once the proxy has started reading its body for the backend, whatever the attempt returns: a body over 64 KiB, an upload the backend answered before it finished, or a request whose stream is still open while the attempt runs. For a method that usually has no body (GET, HEAD, DELETE, OPTIONS, PROPFIND, SEARCH), the transport to an HTTP/1 backend waits briefly to learn whether such a stream carries a body; for any other method, and to an HTTP/2 or h2c backend, it starts reading at once. If the connection failed before the proxy started reading the body, the request is retried with the body untouched. For those methods, if the attempt failed while that read was still waiting on the client, with nothing read yet, the proxy waits for the read to end before deciding: a stream that closes empty is retried, one that delivers data is not. The wait lasts until the client sends data, closes the stream or goes away, or until timeouts.request runs out and the client gets 504 (gRPC: UNAVAILABLE). For any other method, a read still waiting on the client counts as started, so the request is not retried. WebSocket upgrades are never retried. GRPCRoute has no retry field.

Timeouts on a rule with retry:

  • timeouts.backendRequest is the per-attempt header deadline. An attempt that reaches it is abandoned and retried after the backoff.
  • timeouts.request covers every attempt and every backoff wait. When it runs out the in-flight attempt is cancelled and the client gets 504 (gRPC: UNAVAILABLE), even if the last attempt had returned a retriable status. It stops once the final response headers arrive, so as above a streaming body is not cut. It starts after a body with a declared length of at most 64 KiB has been read, which can matter for a slow upload of that size. Any other body is sent inside the budget, so unlike on a rule without retry, a slow upload gets 504 once timeouts.request runs out.
  • Without timeouts.backendRequest, an attempt has no deadline of its own and waits until timeouts.request, or indefinitely when neither is set.

A backoff that cannot be parsed, a negative attempts or backoff, or a code outside 400–599 drops the whole retry policy; the rule is served without retries and the route is marked PartiallyInvalid=True, as for an unparseable timeout.

Backend mTLS (BackendTLSPolicy)

The L7 proxy supports Gateway API BackendTLSPolicy for proxy → backend TLS, with the following minimum-viable scope:

Behaviour Status Notes
CA via same-namespace ConfigMap ref with ca.crt Yes Core support level. The CA bundle is parsed as PEM and rejected with Accepted=False, Reason=NoValidCACertificate if it contains no CERTIFICATE blocks
Multiple CACertificateRefs per policy Yes The bundles of the valid refs are concatenated into the trust pool. An invalid ref among valid ones sets ResolvedRefs=False (Reason=InvalidCACertificateRef or InvalidKind, the message names every invalid ref) and is left out of the pool; the policy stays Accepted. Only when every ref is invalid is the policy Accepted=False, Reason=NoValidCACertificate and its backends fail closed
Hostname as TLS SNI + authentication (when no SANs) Yes Required field; matches stdlib RFC 6125 hostname verification
SubjectAltNames of type Hostname (OR-matching) Yes Cert must match at least one entry. Wildcards in the cert SAN list are honoured via x509.Certificate.VerifyHostname
SubjectAltNames of type URI (e.g. SPIFFE) Yes Matched by exact string equality against the leaf cert's URIs field. OR-matched alongside Hostname SANs in the same policy — either path passing accepts the handshake (matches BackendTLSPolicySANValidation conformance)
SNI / authentication split when SANs are set Yes When ANY SubjectAltNames entry is present (Hostname OR URI), Hostname is used only for SNI and NOT for authentication, per Gateway API spec. Authentication runs against the SAN list
WellKnownCACertificates: System No Only explicit CACertificateRefs are honoured. A policy relying solely on WellKnownCACertificates is rejected with Accepted=False, Reason=Invalid and a message naming the unsupported value and directing the operator to configure explicit caCertificateRefs instead
Multiple targetRefs per policy Yes All targeted Services share the same TLS config
Target kinds Core Service only A targetRef applies only when its group is the core group ("") and its kind is Service, and only to backendRefs and RequestMirror destinations of kind Service. A ServiceImport, ExternalBackend or any other backend never takes the policy of a same-named Service, and a ServiceImport backend never takes that Service's port appProtocol either: it is dialed plaintext over HTTP/1.1 (h2c for gRPC). A targetRef of another group or kind (for example multicluster.x-k8s.io/ServiceImport) attaches to nothing; under each Gateway whose routes use that exact object as a backend the policy reports Accepted=False, Reason=TargetNotFound
SectionName per-port targeting Yes When a TargetRef carries sectionName, only the matching named Service port receives TLS; siblings on the same Service stay plaintext. A policy scoped to a named port and one covering the whole Service select different targets, so both are Accepted, and the scoped one governs its port whatever their creation order. Until the controller sees the Service, the policy applies to every port of it (see Fail-closed enforcement)
Conflict resolution across multiple policies on the same target Yes Oldest-creationTimestamp wins, alphabetical name on tie. Losers are stamped Accepted=False, Reason=Conflicted, Message="conflicts with BackendTLSPolicy <ns/name>" per GEP-713; the upstream BackendTLSPolicyConflictResolution conformance subtest passes. Distinct SectionName scopes do not conflict (a policy targeting all Service ports and another scoped to a specific named port are different scopes). When a losing policy also has an invalid CA, Reason=InvalidCACertificateRef / NoValidCACertificate dominates over Conflicted — the actionable CA error surfaces first
Cross-namespace CA refs No Same-namespace only
GatewayBackendClientCertificate (mutual TLS) Yes The Gateway's spec.tls.backend.clientCertificateRef (Standard channel) loads a kubernetes.io/tls Secret and the proxy presents the keypair during backend TLS handshakes. Cross-namespace refs require ReferenceGrant. The client cert is attached only when the target Service has a BackendTLSPolicy — sending a cert over plaintext is meaningless. A route's backends present the certificate of a parent Gateway that accepted the route and whose data plane serves it. The shared plane serves the Gateways without a dedicated data plane, and a dedicated plane serves its own Gateway; planes that share a tunnel serve each other's Gateways as well, since they carry each other's routes. When several parents qualify, the first of them in parentRefs order with a resolvable client cert wins; foreign-controller parents and parents without a cert are skipped, not blocking. A ListenerSet parent stands for its parent Gateway, so a route attached through a ListenerSet presents that Gateway's certificate under the same rule. The conformance test does not exercise this multi-parent edge case; the project's own end-to-end suite does
HTTPS-listener Re-encrypt (frontend TLS termination + backend TLS) No Cloudflare terminates TLS at the edge, so a protocol: HTTPS listener serves the edge's HTTPS traffic without terminating TLS in the cluster, and re-encrypt behind an in-cluster termination is not possible (see Gateway Listener Configuration). The upstream BackendTLSPolicy parent test is skipped for the same reason

Policy status (Accepted / ResolvedRefs) is maintained per-Gateway-ancestor. A Gateway is an ancestor when a route it accepted sends traffic (a backendRef or a RequestMirror) to the policy's target: a route a ListenerSet accepted counts for the ListenerSet's parent Gateway, and a route in another namespace counts when a ReferenceGrant lets it use the target. A parent that rejected the route carries none of its traffic and is not listed; a new route is listed once its parent accepts it. When no Gateway of this controller is affected any more, the controller removes its own ancestor entries; other controllers' entries are left alone. Edits to the CA ConfigMap (creation, content patch, or deletion), to ReferenceGrants in the policy's namespace, and to a ListenerSet its routes attach to re-trigger status reconciliation. The Status.Ancestors slice is capped at the spec's limit of 16 entries; entries are sorted deterministically by {namespace, name} so the truncated set stays stable across reconciles. Each Gateway left out gets a Warning Event (reason=PolicyAncestorsFull) naming the policy, since the Gateway API defines no condition for it. The policy still applies to that Gateway's traffic: refusing it would break the routes, and leaving it out would send plaintext to a backend that requires TLS. LastTransitionTime is maintained via meta.SetStatusCondition, so it only flips when Status actually changes; Reason and Message updates leave it alone.

Fail-closed enforcement

When a BackendTLSPolicy targets a Service but cannot be enforced — every CA ref is invalid (its ConfigMap is missing, ca.crt is empty, the bundle is malformed PEM), the policy relies on wellKnownCACertificates alone, or it lists a subjectAltNames type the controller does not support — the backendRef is invalid, and requests routed to it get HTTP 500 (gRPC: UNAVAILABLE) without a dial. It is NOT silently downgraded to plaintext. The operator's stated intent ("this hop MUST be authenticated TLS") is preserved. Every route whose backend or RequestMirror points at that Service reports ResolvedRefs=False with Reason=InvalidBackendTLSPolicy and a message naming the policy and the cause; a mirror target only loses the mirrored copies. The policy's own Accepted condition carries the detail as well (Reason=NoValidCACertificate for the CA causes, Reason=Invalid for wellKnownCACertificates).

When the controller cannot tell how to reach a backend safely, it pushes no new config to that data plane. That happens when, for a reason other than the object not existing, the namespace's BackendTLSPolicies cannot be listed, or one of these cannot be read: the Service a sectionName-scoped policy targets, a policy's CA ConfigMap, the backend Service whose appProtocol decides between TLS and plaintext, or what decides the backend client certificate: the parent Gateway, its GatewayClass, a ListenerSet parent, the certificate Secret, or the ReferenceGrant a cross-namespace Secret needs. Unreadable ReferenceGrants of a cross-namespace backendRef hold the push the same way. The proxy keeps serving the config it last accepted, and the controller retries the sync every 15 seconds. Every route change for that data plane waits for the read to succeed, a deleted route included: it keeps being served until then. Other data planes are unaffected. After a controller restart nothing has been pushed yet, so while the read keeps failing, proxy replicas that start in the meantime receive no config and stay unready. Each held-back sync logs proxy config not pushed with the partition and the error, and counts in cftunnel_sync_errors_total{error_type="proxy_push"}. Routes keep their previous ResolvedRefs condition while the push is held; their other conditions still update, so a route created or edited meanwhile can report Accepted for a generation the data plane does not serve yet. A held-back push neither raises nor clears cf.k8s.lex.la/ProxyConfigPushed=False: a data plane whose pushes were already failing before the hold keeps the condition, and one that was healthy does not get it. The policy's own status follows the same rule: when its CA ConfigMap, the other policies in its namespace, the routes, a ReferenceGrant, or a candidate ancestor (a Gateway, its GatewayClass, or a ListenerSet parent) cannot be read, it keeps its current conditions and ancestor entries, and the controller retries.

Until the controller sees the Service a sectionName-scoped policy targets, it cannot map the policy to a port, so a Service reference gets the policy on every port of that Service. A RequestMirror to a Service created moments ago therefore dials TLS with the policy's settings, and the handshake fails if that port does not serve TLS.

Interaction with appProtocol: kubernetes.io/h2c

When a backend Service carries both appProtocol: kubernetes.io/h2c AND a BackendTLSPolicy targets it, the policy wins: the proxy dials TLS (HTTPS, with HTTP/2 negotiated via ALPN). h2c is by definition cleartext HTTP/2 and cannot coexist with backend TLS; the h2c marker is silently ignored on that hop. If you genuinely want HTTP/2 over TLS, omit the appProtocol hint and let ALPN negotiate it during the handshake.

gRPC needs the http2 tunnel transport (auto upgrades for you)

cloudflared does not forward HTTP trailers over QUIC (its default transport): the QUIC response adapter's AddTrailer is a no-op. gRPC carries the mandatory grpc-status in a trailer, so over a QUIC tunnel that 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 do anything for the common case: the proxy waits for the controller's first config push at startup and, when a GRPCRoute is present, dials http2 instead of letting cloudflared negotiate QUIC. A steady-state deploy that already has GRPCRoutes therefore serves gRPC on the default transport. If a GRPCRoute is added after the proxy has already dialed a non-http2 transport, a live re-dial is not safe, so the proxy logs a restart-needed error; restart the proxy (it re-dials on http2) or pin proxy.tunnel.protocol: http2. An explicit proxy.tunnel.protocol: quic is never upgraded — it cannot serve gRPC. The controller both logs an error and sets the GRPCRoute's Accepted condition to False with Reason=UnsupportedProtocol, so kubectl describe grpcroute shows the incompatibility and the remediation (switch to http2 or auto).

auto costs no startup time over a pinned transport: every tunnel-mode proxy, whatever its transport, waits for the controller's first config push before it establishes the tunnel, and auto reads the GRPCRoute signal from that same push. The controller pushes a config even on a route-less cluster (an empty one), so under normal operation the wait ends within seconds. When no push arrives at all (for example, the controller itself is down or cannot reach the proxy), the proxy exits after two minutes without having registered, and kubelet restarts it; see Health Endpoints.

gRPC requires Cloudflare zone gRPC proxying

Separate from the tunnel transport above, the Cloudflare zone must have gRPC proxying enabled (dashboard → Network → gRPC). When it is disabled, the Cloudflare edge returns 403 with content-type: text/html zone-wide for any request whose content-type is application/grpc — the request never reaches the in-cluster proxy.

Because the 403 happens at the edge, upstream of the tunnel, the controller never sees it: the GRPCRoute reports Accepted=True / ResolvedRefs=True while every gRPC call fails. The client-side symptom is opaque:

rpc error: code = PermissionDenied desc = unexpected HTTP status code received from server: 403 (Forbidden); transport: received unexpected content-type "text/html"

The toggle is dashboard-only: there is no zone-settings API for it (PATCH /settings/grpc returns 400 / code 1006 "Unrecognized zone setting name" even with a full-scope token) and the Terraform provider has no resource, so the controller cannot read the setting to validate it. As a breadcrumb, the controller emits a Normal Kubernetes Event (reason: GRPCEdgeProxyingRequired) on every accepted GRPCRoute naming this prerequisite, so kubectl describe grpcroute <name> points at the fix without edge packet inspection.

Fix: enable Network → gRPC for the zone in the Cloudflare dashboard.

Gateway client cert rotation is hot-reloaded

A rotation of the Secret referenced by Gateway.spec.tls.backend.clientCertificateRef enqueues the affected routes directly — ConfigMapper.MapSecretToRequests matches credentials and Gateway-level client-cert Secrets, including cross-namespace refs guarded by a matching ReferenceGrant (from: Gateway, to: Secret). On the resulting reconcile the new keypair is loaded by loadGatewayClientCertPEM, the converter stamps it onto every affected BackendTLSConfig, and the per-cert transport-pool hash on the proxy evicts the stale transport. The next request to that backend handshakes with the rotated keypair.

Frontend listener certificateRefs are not in scope — Cloudflare terminates TLS at the edge, so a protocol: HTTPS listener serves the edge's HTTPS traffic but no TLS is terminated in the cluster with its certificates (see Gateway Listener Configuration).

RequestMirror filter honours BackendTLSPolicy

The Gateway API RequestMirror filter sends a fire-and-forget copy of the request to a secondary backend. When a BackendTLSPolicy targets the mirror destination Service, the converter stamps the resulting BackendTLSConfig on the filter's MirrorConfig and the proxy borrows a per-cert RoundTripper from the same transport pool the main leg uses. The mirror dial then completes a TLS handshake exactly the way the main leg would, so a TLS-only mirror backend receives the mirrored copy correctly and the operator's TLS expectation is preserved on both legs.

Cross-namespace mirror destinations follow the same ReferenceGrant rule as cross-namespace backendRefs — without a permitting grant the mirror is dropped entirely, and deleting the grant stops a running mirror. A dropped mirror (unsupported kind, out-of-range port, or unauthorized cross-namespace ref) is surfaced as ResolvedRefs=False on the route with the mirror-specific reason (InvalidKind / UnsupportedValue / RefNotPermitted); the main request is unaffected, so the route stays Accepted.

A mirror destination must be a Service or a ServiceImport — both resolve to an in-cluster DNS name the converter can build directly. An ExternalBackend is not supported as a mirror destination (only as a primary backend): its URL lives in the CRD spec, which the converter cannot read, and the sentinel-rewrite step does not walk mirror filters. An ExternalBackend mirror ref is therefore dropped with Reason=InvalidKind.

Interaction with appProtocol: https / HTTPS

appProtocol: https (or HTTPS) is treated as a hint that the backend expects TLS — but the proxy cannot dial TLS on its own without a CA to verify against. The behaviour:

  • With a matching BackendTLSPolicy: the policy provides the CA, the proxy dials TLS, and the request goes through normally. The appProtocol hint is redundant but accepted silently.
  • Without a matching BackendTLSPolicy: the backend fails closed — requests routed to it receive HTTP 500 (gRPC: UNAVAILABLE) instead of being dialed in plaintext — and the route's ResolvedRefs condition is set to False, Reason=UnsupportedProtocol with a message naming the fix. kubectl describe httproute shows the dropped backend; attach a BackendTLSPolicy to upgrade the hop to authenticated TLS.

HTTP CORS filter (HTTPRouteCORS)

The L7 proxy honours the Gateway API HTTPCORSFilter for both CORS preflight (OPTIONS + Access-Control-Request-Method) and simple cross-origin requests.

Behaviour Status Notes
Exact-match origins (https://www.foo.com) Yes Compared by full scheme + host + port string
Wildcard host (https://*.bar.com) Yes Greedy left-match against any number of DNS labels; the base domain (bar.com) does NOT match — only proper subdomains do
Universal wildcard ("*" alone) Yes Matches every origin
Port matching Yes When the pattern carries a port, the origin must include the same port (and vice versa). Defaults (80/443) are NOT auto-applied — operators who care about port matching spell it out
allowMethods: ["*"] Yes When the request is uncredentialed and carries Access-Control-Request-Method, the requested method is echoed back. When credentialed AND no requested method is in the header, the proxy omits the Access-Control-Allow-Methods response header entirely rather than emitting * (per spec, * with credentials is forbidden)
allowHeaders: ["*"] Yes Same wildcard-vs-credentials logic as allowMethods
allowCredentials: true Yes The proxy never emits * for Access-Control-Allow-Origin, -Methods, or -Headers while credentials are enabled — it echoes the request's Origin / requested method / requested headers, or omits the header entirely when there is nothing to echo
exposeHeaders Yes Joined with a comma+space separator and stamped on both preflight and simple responses. With allowCredentials: true and exposeHeaders: ["*"] the header is omitted entirely (spec forbids * with credentials)
maxAge Yes Emitted as Access-Control-Max-Age. Defaults to 5 seconds when the policy carries 0 (matches the CRD default; applied at emit time so the controller doesn't need to mirror CRD-default logic)

Preflight handling: a matched OPTIONS preflight short-circuits with HTTP 204 and the negotiated CORS headers — the backend is never hit. A preflight from a non-matched Origin still returns 204 but with no CORS headers, so the browser fails the cross-origin request on the client side. Simple cross-origin requests pass through to the backend and receive Access-Control-Allow-Origin (and credentials/expose headers when applicable) stamped on the way back; same-origin requests (no Origin header) are untouched.

Route Types Not Supported

Route Type Status Reason
TCPRoute Not supported Cloudflare Tunnel is HTTP-focused
TLSRoute Not supported TLS is terminated at edge
UDPRoute Not supported No UDP support in tunnels

Workaround for TCP/UDP

For non-HTTP traffic:

  1. Use Cloudflare Spectrum (separate product)
  2. Use a different ingress solution (LoadBalancer, NodePort)

Full Sync Behavior

Any change to an HTTPRoute or GRPCRoute triggers a full desired-state rebuild from the controller's informer cache:

  1. Controller lists all HTTPRoutes and GRPCRoutes (cached, no API traffic)
  2. Filters by GatewayClass
  3. Rebuilds the entire ingress configuration and diffs it against the deployed one
  4. Writes the tunnel ingress document to the Cloudflare API only when the resulting document differs (the PUT is skipped on steady-state syncs, and the GET too while the document matches the copy the controller read or wrote less than 5 minutes ago)
  5. Pushes the rebuilt L7 routing config to the in-process proxy replicas only when the config content or the replica set changed (content-hash deduplication; a new replica always receives the current config)

Why there is no true incremental sync

The Cloudflare Tunnel configurations endpoint is a whole-document update — there is no rule-level PATCH — so a "delta" write to Cloudflare is not expressible: any change requires writing the full ingress document. The controller therefore optimises the only thing it can: it never writes when nothing changed. The full rebuild itself is cheap — benchmarked at roughly 2 ms for a 500-route fleet (BenchmarkRebuildAndDiff_500Routes) — so reconcile latency is dominated by the (skippable) network calls, not by the rebuild.

Implications

  • A real route change costs one GET + one PUT to Cloudflare and one config push per proxy replica, regardless of how many routes changed.
  • Steady-state route reconciles (status updates, periodic resyncs) cost no Cloudflare call per tunnel, or one GET once the cached document is 5 minutes old, zero PUTs, and no proxy pushes.
  • A change to a proxy EndpointSlice replays the cached config to that data plane's proxy replicas, or to every plane's when the slice cannot be attributed to one, with no Cloudflare call. The replay of every plane runs once and is not retried: a plane it fails to reach is pushed again by its own slice's next change or the next route sync. When the plane's slices list pods but no replica of the plane has accepted a config yet, the event pushes the config the plane's last sync built, or runs a full route sync if none was built yet, as after a controller restart. While none of the plane's pods takes the built config, it is pushed again every ten seconds, with a Warn line, no reconcile error and no Cloudflare call. A route sync that builds nothing for the plane, as with no GatewayClass yet, during a credentials or Cloudflare API outage, or for a broken Gateway, runs once per version of each of the plane's EndpointSlices (a sync error is logged at Error). After it the event still comes back every ten seconds without a Cloudflare call, and pushes the plane's config once another sync has built it.
  • At controller start, each proxy EndpointSlice seen before the first route sync has cached a config can run its own full route sync, alongside the startup sync. They produce the same config, so the extra syncs cost proxy pushes but change nothing; they find the tunnel documents the first sync read or wrote still cached and make no Cloudflare call for them.
  • A route sync started by rendering a per-Gateway data plane, or by a plane's pods appearing before anything was built for it, is run again for as long as it fails or asks for a requeue. A sync asks for one on conditions anywhere in the cluster, such as another tunnel's write failing or a Gateway whose configuration does not resolve. While such a condition lasts, these retries add one full route sync about every 15 seconds, or sooner when the sync asks for it, however many planes triggered them. Each reads only the tunnels whose document is not cached or was cached more than 5 minutes ago, such as one whose write failed.
  • All routes are re-evaluated on any change; full sync remains the startup and recovery path (drift introduced out-of-band is corrected on the next change-triggering sync).

Mitigation

For very large deployments:

  • Batch route changes when possible
  • Monitor Cloudflare API rate limits
  • Consider separating high-churn routes to different tunnels

Route Conflict Resolution

A request first selects a hostname bucket — exact hostname over wildcard over the default (no-hostname) bucket — skipping routes that listener isolation excludes for the request's host. Within that bucket every match is ranked on its own, not as part of its rule: a rule's other matches never lift or lower the one that fired. HTTPRoute matches are ordered as the spec lists the criteria, each one deciding only when every criterion before it ties:

  1. Path match type: exact, then regex, then prefix (where regex sits is implementation-specific)
  2. Longer path value before shorter
  3. A method match
  4. More header matches
  5. More query parameter matches

GRPCRoute matches are ordered by the characters in the matched service, then in the matched method, then by header-match count. The match type (exact or regular expression) is not a criterion. Where an HTTPRoute and a GRPCRoute share a hostname bucket, which happens only across Gateways served by the same data plane, a GRPCRoute match naming a service or method ranks above every HTTPRoute match; the spec defines no order between the two kinds.

The most specific match wins, and the controller does not merge rules from different routes. Hostname only selects the bucket; it is not a precedence tiebreaker among matching rules. When two equally-specific matches from different Routes still tie, the cross-Route tiebreak is applied per the Gateway API spec: the oldest Route by creationTimestamp wins, then the Route first alphabetically by {namespace}/{name}. The within-Route fallback is the first matching rule in list order.

Example Conflict

# Route A
rules:
  - matches:
      - path:
          type: PathPrefix
          value: /api
    backendRefs:
      - name: api-v1

# Route B
rules:
  - matches:
      - path:
          type: PathPrefix
          value: /api/v2
    backendRefs:
      - name: api-v2

Route B's /api/v2 matches first (longer path), then Route A's /api matches remaining traffic.

Case-variant header match names

HTTP header match names are compared case-insensitively at request time (Go's http.Header.Values canonicalises the key), but the CRD enforces name uniqueness case-sensitively via its list-map key. Two header matches in one rule that differ only in case (for example Foo and foo) are therefore both admitted and both required to match, where the spec treats them as equivalent and says only the first should be considered. The effect is a single over-strict (never-matching) rule, not mis-routing; impact is negligible. Query-parameter match names are exact (case-sensitive) string matches per the spec, so Foo and foo are legitimately distinct parameters and are not affected.

Route rule name uniqueness (spec.rules[].name)

Gateway API states that HTTPRouteRule.name / GRPCRouteRule.name MUST be unique within a Route when set. Upstream enforces this only through a CEL admission rule that ships in the experimental-channel CRDs; the Standard-channel CRDs this controller targets omit it, so by default the apiserver admits a Route whose rules share a name. Rule names are used only for status diagnostics, not for routing, so a duplicate does not affect traffic — but the normative MUST is unenforced.

The controller does not add an equivalent runtime check: Gateway API defines no status condition for this violation, the HTTPRoute/GRPCRoute CRDs are owned upstream (the project cannot add the CEL to them), and flagging a working Route as Accepted=False would misreport reality.

To enforce uniqueness at admission, enable the bundled opt-in ValidatingAdmissionPolicy (ruleNameUniquenessPolicy.enabled=true in the Helm chart). It replicates the upstream experimental CEL natively in the apiserver — no webhook server, no CRD ownership — and rejects a Route with duplicate rule names.

Why it is off by default

The policy is cluster-scoped: it applies to every HTTPRoute/GRPCRoute in the cluster (a Route carries no controllerName at admission, so it cannot be limited to this controller's routes) and can block updates to routes that already have duplicate names. Enable it deliberately when those trade-offs are acceptable.

No Native Multi-Cluster Discovery

The controller performs no direct peer-cluster discovery or mesh-style cross-cluster routing on its own. It does not connect clusters together or watch endpoints in remote clusters. Cross-cluster backends ARE reachable, however, through the Multi-Cluster Services (MCS) API: a backendRef targeting a ServiceImport (multicluster.x-k8s.io) resolves to the clusterset.local DNS plane, so the cluster's own MCS implementation routes to the imported remote endpoints (see Non-Service backend kinds).

Workaround

For multi-cluster scenarios:

  1. Deploy the controller in each cluster with separate tunnels
  2. Use Cloudflare Load Balancing to distribute traffic between tunnels
  3. Consider a service mesh with cross-cluster capabilities

GatewayClass behaviour

GatewayClass changes propagate to existing Gateways

The Gateway API spec (gatewayclass_types.go:43) recommends snapshotting GatewayClass configuration when a Gateway is created and NOT propagating later GatewayClass changes; an implementation that does propagate MUST document it. This controller propagates: it watches GatewayClass and, whenever the spec of one of its classes changes, re-reconciles every managed Gateway and re-syncs the routes they accept — notably parametersRef, which points at the GatewayClassConfig holding the Cloudflare credentials and tunnel ID. Credential and tunnel-config updates therefore take effect on already-running Gateways without recreating them.

SupportedVersion condition is verified against the installed CRD bundle

The GatewayClass SupportedVersion condition is an Experimental-channel surface (gatewayclass_types.go:244, marked // <gateway:experimental>); this controller pins and runs the Standard channel (Gateway API v1.6.2), where SupportedVersion is not a required condition. The controller populates it anyway as a best-effort operator signal: on each GatewayClass reconcile it reads the gateway.networking.k8s.io/bundle-version annotation on every Gateway API CRD the controller serves (GatewayClass, Gateway, HTTPRoute, GRPCRoute, ReferenceGrant, ListenerSet and BackendTLSPolicy) and compares each one's major.minor to the Gateway API version the controller is built against (consts.BundleVersion). A matching major.minor sets SupportedVersion=True (patch releases are treated as compatible); an older or newer minor on any of them, a missing annotation, or a missing CRD sets SupportedVersion=False with reason UnsupportedVersion and a message naming the CRD and the mismatch, so a CRD/controller version skew surfaces on status rather than as silent field-drift at runtime.

The gateway-exists finalizer is managed

The Gateway API spec recommends adding the gateway-exists-finalizer.gateway.networking.k8s.io finalizer to a GatewayClass while at least one Gateway uses it. The controller honours this: the finalizer is added when the first Gateway referencing the class appears and removed when the last one goes away, so deleting an in-use GatewayClass blocks until its Gateways are gone.

Policy discoverability conditions stay on the policy

GEP-713 recommends that implementations surface a policy's effect by writing a condition onto the affected objects (the Gateway, or the targeted Service) for discoverability. This controller deviates: BackendTLSPolicy acceptance, conflict, and resolution verdicts are written to the policy's own status.ancestors (namespaced per ancestor Gateway and controller, per GEP-713's ancestor-status mechanism), but no condition is stamped onto the affected Gateway or Service objects. Rationale: Services are user-owned objects whose status this controller deliberately never writes, and the ancestor entries on the policy already name every affected Gateway — kubectl describe backendtlspolicy shows the full effect surface. Use the policy's status, not the Service's, to discover what applies to a backend.

Redirect port

An explicit port in a RequestRedirect filter is honoured, except that Location leaves out 80 for http and 443 for https. When the filter sets a scheme but no port, Location carries no port, because both schemes the CRD allows (http, https) have well-known ports; the spec's fallback to the listener port for a scheme without one is therefore unreachable and not implemented.

When the filter sets neither, the redirect takes the port of the listener the route is attached to, as the spec requires, and leaves it out of Location when it is the scheme's well-known port (80 for http, 443 for https). The proxy cannot tell which listener a request arrived on, so for a route accepted by several listeners of the chosen scheme the well-known port wins, then the lowest one. The choice is made once per route, for all of its hostnames.

Clients reach a listener only through the Cloudflare edge, which accepts a fixed set of ports per scheme. A listener port outside that set is therefore not written into Location, which deviates from the spec's MUST: no request can have arrived on that port, and a redirect to it would fail. To get the listener port into redirects, give the listener a port the edge serves for its scheme. To keep a scheme-less redirect off the listener port, set scheme (which carries no port) or port on the filter.

Metrics and Observability

The controller provides Prometheus metrics for monitoring, but:

  • Per-request access logs are off by default. Enable via proxy.accessLog.enabled: true in Helm values; see Access Logging for the contract, sampling semantics, and the WS-upgrade carve-out. The Cloudflare dashboard remains the only edge-side view (TLS termination, geographic distribution, etc.).
  • Edge-side request visibility (traffic volume, geographic distribution, WAF/firewall events) is available only through the Cloudflare dashboard, not from in-cluster metrics.

Distributed tracing is supported (off by default) via OpenTelemetry. Controller-side Cloudflare API calls and the in-cluster request path both emit spans when enabled. See Distributed Tracing for the end-to-end span model, propagation, and the one known gap (WebSocket backend handshakes are not yet span-propagated).

See Metrics & Alerting for available metrics.