A Kubernetes Gateway API controller that manages Cloudflare Tunnels to expose cluster services to the internet.
The controller watches Gateway, HTTPRoute, and GRPCRoute resources and automatically
provisions Cloudflare tunnels and DNS records to route external traffic to Kubernetes
services — no public IPs or LoadBalancer-type Services required.
The diagram below illustrates the topology for a single Gateway resource. A cluster can have multiple independent Gateways, each will have its own Cloudflare tunnel and set of tunnel replicas in the namespace of the Gateway.
A single Cloudflare tunnel handles all traffic, and proxied CNAME records point each hostname directly to the tunnel. Multiple HTTPRoutes and GRPCRoutes can attach to the same Gateway — each hostname gets its own CNAME. The tunnel container embeds both cloudflared and a reverse proxy to route requests to the correct backend Service by hostname, path, and protocol, with per-request load balancing through kube-proxy.
flowchart LR
C((Client))
C -->|Host: app.example.com| CNA
C -->|Host: api.example.com| CNB
subgraph cfe[Cloudflare edge · L7 proxy]
CNA[CNAME app.example.com]
CNB[CNAME api.example.com]
end
CNA -->|uuid.cfargotunnel.com| T
CNB -->|uuid.cfargotunnel.com| T
subgraph Kubernetes
T[Tunnel replica]
T -->|Host: app.example.com| SA[Service app]
T -->|Host: api.example.com| SB[Service api]
end
T -->|four HTTP/2 connections| cfe
A minimal setup needs a credentials Secret, a GatewayClass, a Gateway, and an HTTPRoute —
no CloudflareGatewayParameters required. Credentials come from the GatewayClass
parametersRef Secret, and DNS management is enabled for all hostnames by default:
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-creds
namespace: default
stringData:
CLOUDFLARE_API_TOKEN: "your-api-token"
CLOUDFLARE_ACCOUNT_ID: "your-account-id"
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: cloudflare
spec:
controllerName: cloudflare-gateway-controller.io/controller
parametersRef:
group: ""
kind: Secret
name: cloudflare-creds
namespace: default
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: my-gateway
namespace: default
spec:
gatewayClassName: cloudflare
listeners:
- name: https
protocol: HTTPS
port: 443
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-route
namespace: default
spec:
parentRefs:
- name: my-gateway
hostnames:
- app.example.com
rules:
- backendRefs:
- name: my-service
port: 80For more control, reference a CloudflareGatewayParameters to configure tunnel replicas for high availability, vertical autoscaling, restrict DNS zones, and more:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: my-gateway
namespace: default
spec:
gatewayClassName: cloudflare
listeners:
- name: https
protocol: HTTPS
port: 443
infrastructure:
parametersRef:
group: cloudflare-gateway-controller.io
kind: CloudflareGatewayParameters
name: my-params
---
apiVersion: cloudflare-gateway-controller.io/v1
kind: CloudflareGatewayParameters
metadata:
name: my-params
namespace: default
spec:
tunnel:
replicas:
- name: us-east-1a
zone: us-east-1a
- name: us-east-1b
zone: us-east-1b
- name: us-east-1c
zone: us-east-1c
autoscaling:
enabled: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
dns:
zones:
- name: example.com
- name: other.comSee the CloudflareGatewayParameters docs for all options.
DNS: The controller creates a CNAME record for each hostname declared in the attached
routes (HTTPRoute and GRPCRoute). Each CNAME points directly to the tunnel address
(<tunnelID>.cfargotunnel.com). When a route hostname is removed, its CNAME is deleted.
If a hostname's CNAME already points to another Gateway's tunnel, the route is rejected
with Accepted=False to prevent DNS conflicts across Gateways.
Cloudflare resources: 1 tunnel, 1 CNAME record per route hostname.
Kubernetes resources: Per Gateway, the controller creates a tunnel Deployment, a tunnel token Secret, a routes ConfigMap, a ServiceAccount, a Role, and a RoleBinding.
Replicas: Multiple named replicas can be configured per tunnel for high availability. Each replica creates a separate Deployment with optional placement controls (zone, nodeSelector, affinity). See CloudflareGatewayParameters for configuration details.
Deployment patches: RFC 6902 JSON Patch operations can be applied to the cloudflared Deployment for advanced customization (e.g. tolerations, node selectors). See CloudflareGatewayParameters for details.
Container resources and autoscaling: CPU and memory requests/limits are configurable for the tunnel container. Vertical Pod Autoscaler (VPA) support is available for automatic resource tuning. See CloudflareGatewayParameters for details.
Token rotation: The controller automatically rotates tunnel tokens on a cron schedule (default: every Thursday at 6 PM America/Los_Angeles time, configurable via CloudflareGatewayParameters). On-demand rotation is also available via the CLI. Rotation updates the in-cluster Secret and performs a rolling restart of the tunnel pods so they pick up the new token.
Observability: The controller creates a
CloudflareGatewayStatus (short name: cgs) per
Gateway, providing a quick view of tunnel info, conditions, and managed resources:
$ kubectl get cgs
NAME TUNNEL ID DNS READY
my-gateway abcd-1234… Enabled True
The cfgwctl CLI provides operational commands for managing Gateways. Binaries are
available from GitHub releases.
# Suspend/resume reconciliation
cfgwctl suspend gateway my-gateway
cfgwctl resume gateway my-gateway
# Trigger on-demand reconciliation
cfgwctl reconcile gateway my-gateway
# Rotate the tunnel token on-demand
cfgwctl rotate gateway token my-gateway
# Watch an ongoing token rotation
cfgwctl watch gateway token my-gatewaySee the CLI documentation for all available commands.
The tunnel container embeds a reverse proxy that solves the load-balancing problem with cloudflared's persistent connections. Without it, cloudflared opens a single long-lived TCP connection to each backend Service, bypassing kube-proxy and pinning all traffic to one pod.
The embedded reverse proxy receives all traffic from cloudflared and routes requests
by hostname, path prefix, and protocol (HTTP vs gRPC) to the correct backend Service.
HTTP requests use HTTP/1.1 with keep-alives disabled so every request opens a fresh
connection through kube-proxy for proper pod-level load balancing. gRPC requests use
HTTP/2 cleartext (h2c) to preserve streaming semantics. When a route rule has multiple
backendRefs with weight fields, the proxy distributes requests across backends
according to their weights (traffic splitting). The proxy also supports
session persistence via cookie-based
or header-based affinity to pin a client to the same backend across requests.
The Cloudflare API token must have the following permissions:
| Permission | Scope | Purpose |
|---|---|---|
| Cloudflare Tunnel: Edit | Account | Create, configure, and delete tunnels in the account |
| DNS: Edit | Zone(s) | Create, update, and delete CNAME records in the zone(s) |