discover · compare · collections · requirements · contribute · feed · watching · stats · help with your tokens
lexfrei/cloudflare-tunnel-gateway-controller · source
Snapshot 0 · 2026-11-04T12:47:28Z · Go · BSD-3-Clause
Content fingerprint
400e8344b310354e6fb007b0548b1c7012fce591313c29d8c954b0fa1766d178GitHub description at capture
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.
README at capture
# Cloudflare Tunnel Gateway Controller
[](https://go.dev/)
[](LICENSE)
[](https://github.com/lexfrei/cloudflare-tunnel-gateway-controller/releases)
[](https://github.com/lexfrei/cloudflare-tunnel-gateway-controller/actions/workflows/pr.yaml)
[](https://cf.k8s.lex.la)
[](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports/v1.6/lexfrei-cloudflare-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 via `appProtocol`
- 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](https://github.com/lexfrei/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](https://cf.k8s.lex.la/latest/development/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](https://cf.k8s.lex.la/latest/operations/troubleshooting/#proxy-pod-stuck-notready-after-a-restart).
## Quick Start
```bash
# 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](#installation) for detailed setup instructions.
## Compatibility
| Component | Supported |
| --- | --- |
| Kubernetes | 1.31+ |
| Gateway API CRDs | Standard channel (Gateway API v1.6.3) |
The [prerequisites page](https://cf.k8s.lex.la/latest/getting-started/prerequisites/#compatibility) explains where each bound comes from.
The conformance badge names the [report accepted upstream](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports/v1.6/lexfrei-cloudflare-tunnel-gateway-controller): 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:
1. Go to [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
2. Navigate to **Networks** > **Tunnels**
3. Click **Create a tunnel**
4. Choose **Cloudflared** connector type
5. 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](https://dash.cloudflare.com/profile/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.
```bash
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](charts/cloudflare-tunnel-gateway-controller/README.md) for all configuration options.
For manual installation without Helm, see [Manual Installation](https://cf.k8s.lex.la/latest/operations/manual-installation/).
## Usage
Create standard [Gateway API](https://gateway-api.sigs.k8s.io/) 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](https://cf.k8s.lex.la/latest/gateway-api/limitations/#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](https://cf.k8s.lex.la/latest/gateway-api/limitations/#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](https://cf.k8s.lex.la/latest/gateway-api/limitations/#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](https://cf.k8s.lex.la/latest/gateway-api/limitations/#client-certificate-validation-spectlsfrontend-is-refused)) |
| `spec.addresses` | ❌ | Only the tunnel CNAME is served: another hostname leaves the Gateway `Programmed=False`, any other address type refuses it (`Accepted=False`) ([details](https://cf.k8s.lex.la/latest/gateway-api/limitations/#specaddresses-accepts-only-the-tunnel-hostname)) |
| `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. `HTTPS` listeners 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's `certificateRefs`.
### 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](https://cf.k8s.lex.la/latest/gateway-api/limitations/#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](https://cf.k8s.lex.la/latest/gateway-api/grpcroute/).
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](https://cf.k8s.lex.la/latest/gateway-api/limitations/) page:
- Edge-side constraints — Cloudflare hostname registration and edge HTTPS termination apply to all traffic: an `HTTPS` listener is served, but clients get the edge certificate, not the listener's `certificateRefs`.
- 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 `HTTP` listener on 80 gets a 404 for HTTPS traffic; add an `HTTPS` listener on 443 to serve it. The port is taken from `Host` as the client sent it, so it separates traffic but is not an access boundary.
- `spec.addresses` can only name the tunnel CNAME: another hostname leaves the Gateway `Programmed=False` with reason `AddressNotUsable`, and any other address type refuses it with reason `UnsupportedAddress`.
- gRPC requires Cloudflare zone gRPC proxying enabled (dashboard → Network → gRPC); otherwise the edge returns `403` zone-wide for `application/grpc`.
- `BackendTLSPolicy` (proxy → backend TLS) is supported at minimum-viable scope: core `Service` targets only, explicit `CACertificateRefs` only, `Hostname` and `URI` SANs, backend mTLS via the Gateway's `clientCertificateRef`. A `ServiceImport` backend is dialed plaintext.
- Backend WebSocket via `appProtocol: kubernetes.io/ws` (and `/wss` with a `BackendTLSPolicy`).
- `timeouts.request` / `timeouts.backendRequest` are 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`, gRPC `UNAVAILABLE`) 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.
- `RequestMirror` copies are dropped once a filter is at its in-flight dispatch cap (`proxy.mirror.maxInFlight`, counted by `cftunnel_proxy_mirror_dropped_total`), so a mirror backend that stops answering cannot grow the proxy's memory with request rate.
- Informational `1xx` responses such as `103 Early Hints` are not forwarded through the tunnel; the client gets only the final response.
- `HTTPRouteRule.name` uniqueness is not enforced at admission; an opt-in `ValidatingAdmissionPolicy` (`ruleNameUniquenessPolicy` Helm value) enforces it.
- The non-canonical `group: core` is accepted for `backendRef`s and `BackendTLSPolicy` CA refs, and rejected for a Gateway's `clientCertificateRef`, listener `certificateRefs` and the `ReferenceGrant` authorising them. Write `group: ""`, the spelling the Gateway API defines, and the asymmetry cannot bite.
- `spec.infrastructure.labels` and `.annotations` reach only Gateways with a dedicated data plane; the shared plane renders nothing per Gateway.
- Knative Serving via `net-gateway-api` needs a split-horizon setup — see the [Knative Serving guide](https://cf.k8s.lex.la/latest/guides/knative-serving/) — 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](https://cf.k8s.lex.la/latest/operations/access-logging/).
See the [Gateway API documentation](https://cf.k8s.lex.la/latest/gateway-api/) 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](https://github.com/kubernetes-sigs/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](https://kubernetes-sigs.github.io/external-dns/latest/docs/sources/gateway-api/) 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](https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/limitations/) 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](https://developers.cloudflare.com/ssl/edge-certificates/advanced-certificate-manager/) or a Business/Enterprise plan.
## Documentation
Full documentation is available at **[cf.k8s.lex.la](https://cf.k8s.lex.la)**.
| Section | Description |
| --- | --- |
| [Getting Started](https://cf.k8s.lex.la/latest/getting-started/) | Prerequisites, installation, and quick start |
| [Configuration](https://cf.k8s.lex.la/latest/configuration/) | Controller configuration and Helm values |
| [Gateway API](https://cf.k8s.lex.la/latest/gateway-api/) | Supported resources, examples, and limitations |
| [Guides](https://cf.k8s.lex.la/latest/guides/) | L7 proxy setup, external-dns, cross-namespace, monitoring |
| [Operations](https://cf.k8s.lex.la/latest/operations/) | Troubleshooting, metrics, manual installation |
| [Development](https://cf.k8s.lex.la/latest/development/) | Development setup, architecture, contributing |
| [Reference](https://cf.k8s.lex.la/latest/reference/) | CRD reference, Helm chart, security policy |
## Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Common development tasks are available via `make`:
```bash
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](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](https://cf.k8s.lex.la/latest/reference/security/).
For multi-tenant deployments — isolation boundaries, hostname-ownership enforcement, and per-Gateway data planes — see the [Multi-Tenancy guide](https://cf.k8s.lex.la/latest/guides/multi-tenancy/).
## License
BSD 3-Clause License - see [LICENSE](LICENSE) for details.