Skip to content

Repository files navigation

cloudflared-operator

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.

How it works

For each CloudflareTunnel CR the operator:

  1. Get-or-creates a remotely-managed Cloudflare tunnel (cfd_tunnel) by name.
  2. Fetches the tunnel's run token into a <name>-token Secret.
  3. Owns a cloudflared Deployment (token-run: cloudflared tunnel --no-autoupdate run --token $(TUNNEL_TOKEN), 2 replicas by default) with /ready health 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:

  1. 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.
  2. 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).
  3. Optionally (tunnels.cloudflare.com/public: "true") exposes the hostname publicly: a proxied CNAME <host> → <tunnelID>.cfargotunnel.com plus 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.

Installation

Prerequisites

  • 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

Helm (recommended)

# 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-operator

Or 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-credentials

The chart installs the CloudflareTunnel CRD (from crds/), the operator Deployment, RBAC, and an IngressClass named cloudflare.

From source

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:dev

Usage

1. Create a tunnel

apiVersion: 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 keys

A 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             True

2. Route an Ingress through it

apiVersion: 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: 6379

The 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/…");

3. Optional: public exposure

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.

Annotations reference

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.

Development

make generate manifests   # regenerate deepcopy + CRD/RBAC after API changes
make test                 # unit + envtest suite
go build ./...            # build
  • api/v1alpha1/ — CRD types
  • internal/cloudflare/ — mockable wrapper over cloudflare-go v7 (tunnels, hostname routes, connectivity-directory VPC services, DNS, tunnel config)
  • internal/controller/CloudflareTunnel and Ingress reconcilers
  • charts/cloudflared-operator/ — Helm chart
  • PLAN.md — design doc

CI/CD

  • .github/workflows/checks.yml — build, vet, unit + envtest, helm lint (Depot runners).
  • .github/workflows/deploy.yml — every push to main releases a new image: Depot remote build (depot/build-push-action, amd64+arm64) pushed to ghcr.io/context-labs/cloudflared-operator:<timestamp>-<sha> and :latest with build provenance and SBOM attestations. The Helm chart is published to oci://ghcr.io/context-labs/charts/cloudflared-operator and deploys :latest by default.

Notes & limitations

  • One global API token/account by default; per-tunnel overrides via spec.credentialsSecretRef.
  • Worker-side vpc_service bindings 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.

License

MIT — see LICENSE.

About

Cloudflare Tunnel's K8s Operator

Resources

Stars

57 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages