A Kubernetes operator that provisions Cloudflare Tunnels
and routes Kubernetes Ingresses through them — in the style of the Tailscale
operator. Create a CloudflareTunnel custom resource, point an Ingress at it,
and the operator wires everything up on the Cloudflare side:
CloudflareTunnel CR ──► cfd_tunnel + run-token Secret + cloudflared Deployment
▲
│ annotation: tunnels.cloudflare.com/tunnel: <name> (required)
Ingress ──► private hostname route (always)
└─► Workers VPC service (optional)
└─► public CNAME + tunnel ingress rule (optional)
There is no default tunnel. The operator never creates a tunnel you didn't
ask for; every Ingress explicitly names the CloudflareTunnel it routes
through. Want one tunnel for everything? Create one CR and point all your
Ingresses at it. Want isolation per team or per network? Create many.
For each CloudflareTunnel CR the operator:
- Get-or-creates a remotely-managed Cloudflare tunnel (
cfd_tunnel) by name. - Fetches the tunnel's run token into a
<name>-tokenSecret. - Owns a
cloudflaredDeployment (token-run:cloudflared tunnel --no-autoupdate run --token $(TUNNEL_TOKEN), 2 replicas by default) with/readyhealth probes. Status shows the tunnel ID and live connection count.
For each Ingress with ingressClassName: cloudflare and the required
tunnels.cloudflare.com/tunnel: <name> annotation the operator:
- Always creates a private hostname route
(
POST /accounts/{acc}/zerotrust/routes/hostname) mapping the hostname to the tunnel. Inside the cluster, cloudflared resolves that hostname via cluster DNS to your backend Service — no public DNS needed. This is how Workers reach in-cluster services through the tunnel. - Optionally (
tunnels.cloudflare.com/vpc-service: <actual-name>) creates a Workers VPC service in the connectivity directory (type: tcp,tcp_port,host.resolver_network.tunnel_id) so Cloudflare Workers can call the backend with a{ type: "vpc_service", service_id }binding (configured in your Worker's own wrangler config). - Optionally (
tunnels.cloudflare.com/public: "true") exposes the hostname publicly: a proxied CNAME<host> → <tunnelID>.cfargotunnel.complus a remote ingress rule on the tunnel's configuration.
Deleting an Ingress removes exactly the Cloudflare resources it created (IDs
are tracked in status annotations); deleting a CloudflareTunnel removes the
Deployment, token Secret, and the Cloudflare tunnel itself.
- Kubernetes 1.25+
- A Cloudflare API token with permissions: Zero Trust tunnels (edit), connectivity directory (edit), and DNS (edit) for the zones you want public exposure on
- Your Cloudflare account ID
# OCI chart published to GHCR on every push to main; pulls the :latest signed image
helm install cloudflared-operator \
oci://ghcr.io/context-labs/charts/cloudflared-operator \
--namespace cloudflared-operator --create-namespace \
--set cloudflare.apiToken=$CLOUDFLARE_API_TOKEN \
--set cloudflare.accountId=$CLOUDFLARE_ACCOUNT_ID
# or from a checkout of this repo:
helm install cloudflared-operator ./charts/cloudflared-operator ...Images are published with build provenance + SBOM attestations (Sigstore/GitHub OIDC). Verify before deploying:
gh attestation verify oci://ghcr.io/context-labs/cloudflared-operator:latest \
-R context-labs/cloudflared-operatorOr use an existing Secret:
kubectl create namespace cloudflared-operator
kubectl -n cloudflared-operator create secret generic cf-credentials \
--from-literal=CLOUDFLARE_API_TOKEN=$CLOUDFLARE_API_TOKEN \
--from-literal=CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID
helm install cloudflared-operator ./charts/cloudflared-operator \
--namespace cloudflared-operator \
--set cloudflare.existingSecret=cf-credentialsThe chart installs the CloudflareTunnel CRD (from crds/), the operator
Deployment, RBAC, and an IngressClass named cloudflare.
make install # install CRDs
make run # run the manager locally (needs CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID env)
# or
make docker-build docker-push IMG=ghcr.io/context-labs/cloudflared-operator:dev
make deploy IMG=ghcr.io/context-labs/cloudflared-operator:devapiVersion: tunnels.cloudflare.com/v1alpha1
kind: CloudflareTunnel
metadata:
name: redis-cache-azure-development
spec:
replicas: 2
# deleteTunnelOnRemove: true # delete the CF tunnel when this CR is removed (default)
# credentialsSecretRef: # optional: per-tunnel Cloudflare credentials
# name: cf-alt-credentials # Secret with CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID keysA tunnel CR cannot be deleted while Ingresses still reference it — the
finalizer holds and a TunnelInUse warning event lists the blockers. Delete
the Ingresses first (Cloudflare itself also refuses to delete tunnels that
still have routes).
Per-tunnel credentials: set spec.credentialsSecretRef to a Secret (same
namespace) containing CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to
run that tunnel against a different Cloudflare account or with a differently
scoped token. All operations for the tunnel — including Ingresses routed
through it — use the override.
kubectl get cloudflaretunnels
# NAME TUNNEL ID CONNECTIONS READY
# redis-cache-azure-development de5136fc-1f27-483f-b61e-395060a208cf 8 TrueapiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: redis-cache
namespace: redis-cf-tunnel
annotations:
tunnels.cloudflare.com/tunnel: redis-cache-azure-development # required
tunnels.cloudflare.com/vpc-service: redis-cache-dev # optional, Workers VPC
spec:
ingressClassName: cloudflare
rules:
- host: redis-cache-azure-dev.redis-cf-tunnel.svc.cluster.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: redis-cache
port:
number: 6379The operator creates the hostname route and the VPC service
redis-cache-dev. A Worker can then reach Redis over the tunnel:
// wrangler.toml: [[vpc_services]] binding = { service_id = "…", name = "REDIS" }
const resp = await env.REDIS.fetch("http://redis-cache-azure-dev.redis-cf-tunnel.svc.cluster.local:6379/…");metadata:
annotations:
tunnels.cloudflare.com/tunnel: my-tunnel
tunnels.cloudflare.com/public: "true"
spec:
ingressClassName: cloudflare
rules:
- host: demo.example.com # must be in a Cloudflare zone on your account
...This creates the proxied DNS record and the tunnel ingress rule, so
https://demo.example.com is served by the Kubernetes Service through the
tunnel.
| Annotation | Required | Meaning |
|---|---|---|
tunnels.cloudflare.com/tunnel |
yes | Name of the CloudflareTunnel CR (same namespace) to route through. Missing → Warning event, Ingress skipped. |
tunnels.cloudflare.com/vpc-service |
no | Actual name for a Workers VPC service pointing at this Ingress. Literal "true"/"" is rejected. |
tunnels.cloudflare.com/vpc-port |
no | Override the VPC service port (default: the Ingress backend port). |
tunnels.cloudflare.com/public |
no | "true" creates a proxied CNAME + remote tunnel ingress rule. |
tunnels.cloudflare.com/hostname |
no | Override the hostname (default: first rule host, else <name>.<namespace>.svc.cluster.local). |
Created Cloudflare resource IDs are written back as status annotations
(tunnels.cloudflare.com/hostname-route-id, -service-id, -dns-record-id)
so removal is precise.
make generate manifests # regenerate deepcopy + CRD/RBAC after API changes
make test # unit + envtest suite
go build ./... # buildapi/v1alpha1/— CRD typesinternal/cloudflare/— mockable wrapper over cloudflare-go v7 (tunnels, hostname routes, connectivity-directory VPC services, DNS, tunnel config)internal/controller/—CloudflareTunnelandIngressreconcilerscharts/cloudflared-operator/— Helm chartPLAN.md— design doc
.github/workflows/checks.yml— build, vet, unit + envtest, helm lint (Depot runners)..github/workflows/deploy.yml— every push tomainreleases a new image: Depot remote build (depot/build-push-action, amd64+arm64) pushed toghcr.io/context-labs/cloudflared-operator:<timestamp>-<sha>and:latestwith build provenance and SBOM attestations. The Helm chart is published tooci://ghcr.io/context-labs/charts/cloudflared-operatorand deploys:latestby default.
- One global API token/account by default; per-tunnel overrides via
spec.credentialsSecretRef. - Worker-side
vpc_servicebindings are managed in the Worker's own config, not by this operator. - The Cloudflare tunnel refuses deletion while hostname routes still point at it — delete dependent Ingresses first.
MIT — see LICENSE.