lexfrei/cloudflare-tunnel-gateway-controller
lexfrei/cloudflare-tunnel-gateway-controller has 47 GitHub stars and 10 forks. GitHot measured +1 stars in 24 hours. Last checked 2026-11-09 15:54 UTC. Last push 2026-11-08.
Kubernetes Gateway API implementation for Cloudflare Tunnel. HTTPRoute and GRPCRoute are served by an in-cluster L7 proxy with the cloudflared transport built in. Gateway API conformant.
What you can do
- 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
Measured star growth
- in 24h
- +1
- in 7 days
- Update delayed
- in 15 days
- Update delayed
- in 1 month
- Update delayed
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
OriginalCloudflare Tunnel Gateway Controller
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.
Features
- 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.
L7 Proxy
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.
Quick Start
# 1. Install Gateway API CRDs
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.3/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
EOF
See Installation for detailed setup instructions.
Compatibility
| Component | Supported |
|---|---|
| Kubernetes | 1.31+ |
| Gateway API CRDs | Standard channel (Gateway API v1.6.3) |
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.
Prerequisites
- Kubernetes cluster with Gateway API CRDs installed
- Cloudflare account with a pre-created Cloudflare Tunnel
- Cloudflare API token with tunnel permissions
Create Cloudflare Tunnel
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.
Cloudflare API Token Permissions
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).
Installation
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.yaml
See charts/cloudflare-tunnel-gateway-controller/README.md for all configuration options.
For manual installation without Helm, see Manual Installation.
Usage
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.
Supported Gateway Fields
| 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; an HTTP listener serves only plain-HTTP requests and an HTTPS listener only HTTPS ones (listener ports and schemes) |
spec.listeners[].port |
✅ | A request is served only by routes on a listener with the port it arrived on: 80 for HTTP and 443 for HTTPS unless the URL names another port the edge proxies (listener ports and schemes) |
spec.listeners[].hostname |
✅ | Routes must have intersecting hostnames; a request is served only by routes on the most specific listener that matches its host, port and scheme (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. A class can make one the default for all its Gateways |
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.
Supported Route Fields
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.
Limitations
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. - A route answers only requests on its listener's port and scheme, so a Gateway with only an
HTTPlistener on 80 gets a 404 for HTTPS traffic; add anHTTPSlistener on 443 to serve it. The port is taken fromHostas the client sent it, so it separates traffic but is not an access boundary. 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. spec.infrastructure.labelsand.annotationsreach only Gateways with a dedicated data plane; the shared plane renders nothing per Gateway.- 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.
External-DNS Integration
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.
FAQ
Why do I get SSL certificate errors for multi-level subdomains?
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.
Documentation
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 |
Contributing
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 linter
Security
For 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.
License
BSD 3-Clause License - see LICENSE for details.
Explore related projects
awesome-selfhosted/awesome-selfhosted
A list of Free Software network services and web applications which can be hosted on your own servers
335,769 starsimmich-app/immich
High performance self-hosted photo and video management solution.
118,645 starsStirling-Tools/Stirling-PDF
#1 PDF Application on GitHub that lets you edit PDFs on any device anywhere
96,149 starslouislam/uptime-kuma
A fancy self-hosted monitoring tool
93,425 stars