Kubernetes controller implementing Gateway API for Cloudflare Tunnel.
Expose in-cluster services through a Cloudflare Tunnel using standard Gateway API resources (Gateway, HTTPRoute, GRPCRoute), with no public load balancer or inbound firewall rule.
- Standard Gateway API: GatewayClass, Gateway, HTTPRoute, GRPCRoute, ListenerSet
- In-process L7 reverse proxy embeds the cloudflared transport — a single data plane handles routing and tunnel egress
- Hot reload of routing configuration (no cloudflared restart on route changes)
- Path matching (prefix, exact, regex), header / query-parameter / HTTP-method matching
- Request and response header modification, URL rewrite, request redirect, request mirror
- Weighted traffic splitting across backends
- HTTPRoute CORS filter
- Cross-namespace backend references gated by ReferenceGrant
- Backend TLS (
BackendTLSPolicy) and backend WebSocket viaappProtocol - Multi-tenant isolation: per-namespace hostname-ownership enforcement (admission policy + controller), route-collision detection, and optional per-Gateway data planes (a dedicated proxy and tunnel per Gateway, where Cloudflare must confirm a Gateway's connector token before its tunnel is written to, a Gateway claiming a tunnel another namespace already serves is refused, and an operator can cap how many dedicated data planes one namespace may run)
- Request-level Prometheus metrics from the proxy data plane (per-hostname rates, latency, in-flight gauge for autoscaling)
- Leader election for high-availability deployments
- Multi-arch images (amd64, arm64), signed with cosign
Warning: The controller assumes exclusive ownership of the tunnel configuration. It removes any ingress rules not managed by HTTPRoute/GRPCRoute resources. Do not point it at a tunnel that has manually configured routes or is shared with other systems.
The controller runs an in-process L7 reverse proxy inside the cloudflared process via the OverrideProxy hook (using a fork of cloudflared). All tunnel traffic is intercepted by the proxy, which applies Gateway API routing rules — hostname, path, header, query and method matching, filters, and weighted backend selection — before forwarding to backends. The tunnel ingress document the controller writes through the Cloudflare API only feeds the Cloudflare dashboard; request routing is handled entirely by the proxy, so HTTPRoute features work end-to-end regardless of Cloudflare Tunnel's native capabilities.
See L7 Proxy Architecture for full details.
If the tunnel's initial connection to the Cloudflare edge fails (for example, cluster DNS not yet reachable right after a node reboot), the proxy retries with backoff and reports NotReady instead of restarting — see Proxy Pod Stuck NotReady After a Restart.
# 1. Install Gateway API CRDs
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.2/standard-install.yaml
# 2. Create credentials Secrets
kubectl create namespace cloudflare-tunnel-system
kubectl create secret generic cloudflare-credentials \
--namespace cloudflare-tunnel-system \
--from-literal=api-token="YOUR_API_TOKEN"
kubectl create secret generic cloudflare-tunnel-token \
--namespace cloudflare-tunnel-system \
--from-literal=tunnel-token="YOUR_TUNNEL_TOKEN"
# 3. Install the controller
helm install cloudflare-tunnel-gateway-controller \
oci://ghcr.io/lexfrei/charts/cloudflare-tunnel-gateway-controller \
--namespace cloudflare-tunnel-system \
--set gatewayClassConfig.create=true \
--set gatewayClassConfig.tunnelID=YOUR_TUNNEL_ID \
--set gatewayClassConfig.cloudflareCredentialsSecretRef.name=cloudflare-credentials \
--set proxy.tunnelTokenSecretRef.name=cloudflare-tunnel-token
# 4. Create HTTPRoute to expose your service
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
spec:
parentRefs:
- name: cloudflare-tunnel
namespace: cloudflare-tunnel-system
hostnames:
- app.example.com
rules:
- backendRefs:
- name: my-service
port: 80
EOFSee Installation for detailed setup instructions.
| Component | Supported |
|---|---|
| Kubernetes | 1.31+ |
| Gateway API CRDs | Standard channel (Gateway API v1.6.2) |
The prerequisites page explains where each bound comes from.
The conformance badge names the report accepted upstream: release v3.3.1, run with conformance suite v1.6.1. It moves when a newer report is accepted, not when the Gateway API version above changes.
- Kubernetes cluster with Gateway API CRDs installed
- Cloudflare account with a pre-created Cloudflare Tunnel
- Cloudflare API token with tunnel permissions
Before deploying the controller, you must create a Cloudflare Tunnel:
- Go to Cloudflare Zero Trust Dashboard
- Navigate to Networks > Tunnels
- Click Create a tunnel
- Choose Cloudflared connector type
- Name your tunnel and save the Tunnel ID and Tunnel Token
The controller manages tunnel ingress configuration via the Cloudflare API. Tunnel traffic is terminated by the in-process L7 proxy that ships with the chart; supply the tunnel token via proxy.tunnelTokenSecretRef in Helm values.
Create an API token at Cloudflare API Tokens with the following permissions:
| Scope | Permission | Access |
|---|---|---|
| Account | Cloudflare Tunnel | Edit |
Account ID is auto-detected from the API token when not explicitly provided (works if the token has access to a single account).
Helm is the only supported installation method. It handles CRD installation, RBAC setup, and provides a simple upgrade path.
helm install cloudflare-tunnel-gateway-controller \
oci://ghcr.io/lexfrei/charts/cloudflare-tunnel-gateway-controller \
--namespace cloudflare-tunnel-system \
--create-namespace \
--values values.yamlSee charts/cloudflare-tunnel-gateway-controller/README.md for all configuration options.
For manual installation without Helm, see Manual Installation.
Create standard Gateway API HTTPRoute or GRPCRoute resources referencing the cloudflare-tunnel Gateway. The controller syncs routes to the in-process L7 proxy and the Cloudflare Tunnel configuration with hot reload (no cloudflared restart). Both HTTPRoute and GRPCRoute are served by the proxy at runtime.
| Field | Supported | Notes |
|---|---|---|
spec.gatewayClassName |
✅ | Must match controller's GatewayClass |
spec.listeners |
✅ | Fully processed for route binding and status |
spec.listeners[].name |
✅ | Used for route binding, status reporting, attached route counting |
spec.listeners[].protocol |
✅ | HTTP/HTTPS listeners bind HTTPRoute and GRPCRoute |
spec.listeners[].port |
✅ | Used for route binding when route specifies a port, and as the redirect port of a scheme-less RequestRedirect (redirect port) |
spec.listeners[].hostname |
✅ | Routes must have intersecting hostnames; a host is served only by routes on the most specific listener that matches it (listener isolation) |
spec.listeners[].tls |
✅ | certificateRefs validated, with ReferenceGrant support, but never served: clients get the Cloudflare edge certificate |
spec.listeners[].allowedRoutes |
✅ | Namespace (Same/All/Selector) and kind filtering |
spec.tls.frontend |
❌ | Refused: the Gateway is Accepted=False and its routes are not served; validate client certificates at the Cloudflare edge instead (details) |
spec.addresses |
❌ | Only the tunnel CNAME is served: another hostname leaves the Gateway Programmed=False, any other address type refuses it (Accepted=False) (details) |
spec.infrastructure.parametersRef |
✅ | Opts the Gateway into a dedicated data plane (GatewayConfig, group cf.k8s.lex.la) — its own proxy and tunnel |
spec.infrastructure.labels / .annotations |
✅ | Propagated to the rendered per-Gateway resources, generated Secrets and pod template |
Note: Cloudflare Tunnel terminates TLS at its edge.
HTTPSlisteners are accepted and served, and their certificate references are validated (including cross-namespace ReferenceGrant checks), but the client gets the Cloudflare edge certificate, not the listener'scertificateRefs.
All matching and filter behavior is performed by the in-process L7 proxy that the chart deploys alongside the controller.
HTTPRoute:
| Field | Supported | Notes |
|---|---|---|
spec.hostnames |
✅ | Wildcard * supported |
spec.rules[].matches[].path |
✅ | PathPrefix, Exact, RegularExpression |
spec.rules[].matches[].headers |
✅ | Exact and RegularExpression |
spec.rules[].matches[].queryParams |
✅ | Exact and RegularExpression |
spec.rules[].matches[].method |
✅ | All HTTP methods |
spec.rules[].filters |
✅ | Header modifier, redirect, URL rewrite, mirror, CORS |
spec.rules[].backendRefs |
✅ | Service name, namespace, port |
spec.rules[].backendRefs[].namespace |
✅ | Cross-namespace refs require ReferenceGrant |
spec.rules[].backendRefs[].weight |
✅ | True weighted traffic splitting across backends |
spec.rules[].retry |
✅ | Experimental channel; see Retries |
GRPCRoute: ✅ Served by the in-process L7 proxy. gRPC service/method matches map onto /{service}/{method} path rules. The upstream hop is cleartext h2c by default; attaching a BackendTLSPolicy upgrades the hop to TLS with HTTP/2 negotiated via ALPN, and the Gateway's clientCertificateRef is presented on the handshake for mTLS. See GRPCRoute docs.
A backendRef may target a core Service, a ServiceImport (multicluster.x-k8s.io), or an ExternalBackend (cf.k8s.lex.la, an out-of-cluster HTTP(S) URL). Other kinds are reported ResolvedRefs=False, InvalidKind.
The L7 proxy handles routing for every tunnel request, so most Gateway API behavior works end-to-end. The caveats that remain are documented in full on the Limitations page:
- Edge-side constraints — Cloudflare hostname registration and edge HTTPS termination apply to all traffic: an
HTTPSlistener is served, but clients get the edge certificate, not the listener'scertificateRefs. - A Gateway that sets
spec.tls.frontend(client certificate validation) is refused, because clients complete TLS with the Cloudflare edge; require client certificates at the edge instead. spec.addressescan only name the tunnel CNAME: another hostname leaves the GatewayProgrammed=Falsewith reasonAddressNotUsable, and any other address type refuses it with reasonUnsupportedAddress.- gRPC requires Cloudflare zone gRPC proxying enabled (dashboard → Network → gRPC); otherwise the edge returns
403zone-wide forapplication/grpc. BackendTLSPolicy(proxy → backend TLS) is supported at minimum-viable scope: coreServicetargets only, explicitCACertificateRefsonly,HostnameandURISANs, backend mTLS via the Gateway'sclientCertificateRef. AServiceImportbackend is dialed plaintext.- Backend WebSocket via
appProtocol: kubernetes.io/ws(and/wsswith aBackendTLSPolicy). timeouts.request/timeouts.backendRequestare enforced as header-only deadlines, so streaming responses (SSE, chunked, gRPC server-streaming) keep flowing past the deadline.retry(Experimental channel) retries every method at most 10 times, at least 10ms apart, and a request body over 64 KiB, or a streamed one the first attempt did not finish sending, is not resent once the proxy has started reading it.- Unavailable backends in a weighted rule return a status (
500/503, gRPCUNAVAILABLE) for their share rather than dialing a dead address, so the other backends keep serving. - A match pattern the proxy cannot compile drops its own rule, reported on the route that carries it; other rules and other routes keep serving.
RequestMirrorcopies are dropped once a filter is at its in-flight dispatch cap (proxy.mirror.maxInFlight, counted bycftunnel_proxy_mirror_dropped_total), so a mirror backend that stops answering cannot grow the proxy's memory with request rate.- Informational
1xxresponses such as103 Early Hintsare not forwarded through the tunnel; the client gets only the final response. HTTPRouteRule.nameuniqueness is not enforced at admission; an opt-inValidatingAdmissionPolicy(ruleNameUniquenessPolicyHelm value) enforces it.- The non-canonical
group: coreis accepted forbackendRefs andBackendTLSPolicyCA refs, and rejected for a Gateway'sclientCertificateRef, listenercertificateRefsand theReferenceGrantauthorising them. Writegroup: "", the spelling the Gateway API defines, and the asymmetry cannot bite. - Knative Serving via
net-gateway-apineeds a split-horizon setup — see the Knative Serving guide — because its readiness prober dials the Gateway's tunnel address directly, which is not reachable in-cluster.
The proxy can emit a structured per-request access log via proxy.accessLog.enabled: true. See Access Logging.
See the Gateway API documentation for full details and examples.
The controller sets status.addresses on the Gateway with the tunnel CNAME (TUNNEL_ID.cfargotunnel.com). If you run external-dns with the Gateway API source, it will automatically create DNS records for your HTTPRoute hostnames.
All external-dns annotations (TTL, provider-specific settings, etc.) should be placed on HTTPRoute resources, not on the Gateway. See the external-dns Gateway API documentation for details.
A Gateway with its own data plane keeps that address while its configuration is broken, so external-dns does not withdraw the record on a configuration error. The address is what records which tunnel the plane holds, and clearing it would surrender that tunnel. Gateways on the shared plane clear it as before.
Cloudflare's free Universal SSL certificates only cover root and first-level subdomains:
- ✅
example.com,*.example.com - ❌
app.dev.example.com
For multi-level subdomains, you need Advanced Certificate Manager or a Business/Enterprise plan.
Full documentation is available at cf.k8s.lex.la.
| Section | Description |
|---|---|
| Getting Started | Prerequisites, installation, and quick start |
| Configuration | Controller configuration and Helm values |
| Gateway API | Supported resources, examples, and limitations |
| Guides | L7 proxy setup, external-dns, cross-namespace, monitoring |
| Operations | Troubleshooting, metrics, manual installation |
| Development | Development setup, architecture, contributing |
| Reference | CRD reference, Helm chart, security policy |
Contributions are welcome! Please read CONTRIBUTING.md for guidelines.
Common development tasks are available via make:
make help # list all targets
make check-deps # verify required tools are installed
make test # run tests
make lint # run linterFor security issues, please see SECURITY.md.
The proxy's config API (where the controller pushes routing changes) is authenticated, served over TLS with certificates the controller issues itself, and network-restricted by default — see the Security reference.
For multi-tenant deployments — isolation boundaries, hostname-ownership enforcement, and per-Gateway data planes — see the Multi-Tenancy guide.
BSD 3-Clause License - see LICENSE for details.