A Kubernetes Operator for managing Cloudflare Zero Trust resources from your cluster.
The operator consists of:
- Custom Resource Definitions (CRDs):
CloudflareZeroTrustTenant- Cloudflare account/zone/tunnel configuration with credentialsCloudflareZeroTrustTemplate- Reusable configuration templates for tunnel routes and Access applicationsCloudflareZeroTrustOperatorConfig- Operator pod configuration (singleton)
- Controller: kopf-based Python reconciliation loop that watches Kubernetes resources and manages Cloudflare
- Template-driven configuration: HTTPRoutes use minimal annotations to select templates with full configuration
flowchart TB
subgraph k8s["Kubernetes Cluster"]
tenant["CloudflareZeroTrust\nTenant CR"]
httproute["Gateway API HTTPRoute\n(with annotations)"]
subgraph operator["Operator Pod (kopf)"]
reconciler["Reconciler"]
cleanup["Orphan Cleanup Timer"]
end
tenant --> reconciler
httproute --> reconciler
end
subgraph cf["Cloudflare Zero Trust Platform"]
tunnels["Tunnel Hostnames"]
access_apps["Access Apps"]
access_policies["Access Policies"]
service_tokens["Service Tokens"]
end
reconciler -->|"Cloudflare SDK"| cf
cleanup -->|"Cloudflare SDK"| cf
- Watch Phase: List CloudflareZeroTrustTenant CRs and Gateway API HTTPRoute resources
- Analysis Phase: For each HTTPRoute with cfzt annotations, determine desired Cloudflare state
- Sync Phase: Call Cloudflare APIs to create/update/delete resources
- Update Phase: Patch HTTPRoute annotations with Cloudflare resource IDs
- Status Phase: Update CloudflareZeroTrustTenant status with sync results
The operator uses templates to configure tunnel routes and Access applications. This keeps HTTPRoutes clean and enables reusable configurations.
Only specify what's unique to each route:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: my-app
annotations:
# Enable operator management
cfzt.cloudflare.com/enabled: "true"
# Public hostname (REQUIRED)
cfzt.cloudflare.com/hostname: "my-app.example.com"
# Template to use (optional)
cfzt.cloudflare.com/template: "protected-adauth"
# Tenant to use (required)
cfzt.cloudflare.com/tenant: "my-tenant"
spec:
parentRefs:
- name: default
namespace: my-app
hostnames:
- "my-app.example.com"
rules:
- backendRefs:
- name: my-app
port: 8080Create reusable templates with full configuration. The operator resolves policy names to UUIDs at reconcile time — no need to look them up manually.
apiVersion: cfzt.cloudflare.com/v1alpha1
kind: CloudflareZeroTrustTemplate
metadata:
name: protected-adauth
namespace: cloudflare-zero-trust-operator
spec:
accessApplication:
enabled: true
sessionDuration: "24h"
existingPolicyNames:
- "ADAUTH" # Cloudflare Access policy name — resolved to UUID at reconcile time
autoRedirectToIdentity: true
appLauncherVisible: true
skipInterstitial: false
httpOnlyCookieAttribute: true
sameSiteCookieAttribute: "lax"Settings are resolved through a three-way merge:
- Base template (
base-<tenant-name>) — auto-discovered by the operator; establishes origin/TLS defaults for every route under a tenant. Create a template namedbase-<your-tenant-name>and the operator picks it up automatically — no field on the tenant CR is needed. - Per-route template — referenced via
cfzt.cloudflare.com/template; only fields specified here override the base. - Annotation overrides — fields set directly on the HTTPRoute that override both templates.
The operator ships example templates (installed by the Helm chart when exampleTemplates.install: true):
| Template Name | Description | Use Case |
|---|---|---|
base-<tenant-name> |
Origin/TLS baseline for all routes | Every tenant needs one — name must match base-<tenant-name> |
protected-adauth |
Identity-based auth via existing Access policy | Human/browser consumers (IdP login) |
unprotected-public |
No Access gate | Intentionally public services |
protected-service-cred |
M2M auth via service credential policy | APIs and CLI consumers |
internal-dnsonly |
DNS A record only, no tunnel | Point hostname at private cluster IP |
See examples-templates.md for complete template definitions.
| Annotation | Required | Description | Example |
|---|---|---|---|
cfzt.cloudflare.com/enabled |
Yes | Enable operator management | "true" |
cfzt.cloudflare.com/hostname |
Yes | Public hostname to configure | "app.example.com" |
cfzt.cloudflare.com/tenant |
Yes | Name of the CloudflareZeroTrustTenant CR |
"my-tenant" |
cfzt.cloudflare.com/template |
No | Template name | "protected-adauth" |
cfzt.cloudflare.com/dnsIp |
No | Private RFC 1918 IP for internal-dnsonly routes |
"192.168.1.50" |
HTTPRoute annotations can override any field from the merged template for that specific route:
| Annotation | Description |
|---|---|
cfzt.cloudflare.com/origin.url |
Override origin URL |
cfzt.cloudflare.com/origin.noTLSVerify |
Override TLS verification skip |
cfzt.cloudflare.com/access.enabled |
Override Access Application enabled flag |
cfzt.cloudflare.com/access.existingPolicyNames |
Comma-separated policy names |
cfzt.cloudflare.com/access.sessionDuration |
Override session duration |
cfzt.cloudflare.com/access.autoRedirectToIdentity |
Override auto-redirect to IdP |
cfzt.cloudflare.com/access.appLauncherVisible |
Override App Launcher visibility |
cfzt.cloudflare.com/access.serviceAuth401Redirect |
Return 401 instead of browser redirect |
cfzt.cloudflare.com/access.skipInterstitial |
Override interstitial page |
cfzt.cloudflare.com/access.httpOnlyCookieAttribute |
Override HttpOnly cookie flag |
cfzt.cloudflare.com/access.sameSiteCookieAttribute |
Override SameSite cookie value (none/lax/strict) |
cfzt.cloudflare.com/serviceToken.enabled |
Override Service Token enabled flag |
cfzt.cloudflare.com/serviceToken.duration |
Override Service Token duration |
These annotations are set by the operator to track created resources:
cfzt.cloudflare.com/hostnameRouteId: Tunnel hostname route IDcfzt.cloudflare.com/accessAppId: Access Application IDcfzt.cloudflare.com/accessPolicyIds: Comma-separated Access Policy IDscfzt.cloudflare.com/serviceTokenId: Service Token IDcfzt.cloudflare.com/serviceTokenSecretName: K8s Secret name containing token credentials
apiVersion: cfzt.cloudflare.com/v1alpha1
kind: CloudflareZeroTrustTemplate
metadata:
name: my-template
namespace: cloudflare-zero-trust-operator
spec:
# Origin service configuration
# Typically defined only in the base-<tenant-name> template and inherited by per-route templates.
originService:
url: "https://traefik.traefik.svc.cluster.local:443"
httpRedirect: true # Auto-redirect HTTP to HTTPS at edge
originTLS:
noTLSVerify: true # Skip TLS verification for self-signed certs
originServerName: "" # Custom SNI hostname (optional)
caPool: "" # Path to CA cert file (optional)
tlsTimeout: 10 # TLS handshake timeout in seconds (1-300)
http2Origin: false # Use HTTP/2 for origin connection
matchSNIToHost: false # Match SNI to Host header
# Access Application configuration
accessApplication:
enabled: true
sessionDuration: "24h"
# Policy names — operator resolves to UUIDs at reconcile time
existingPolicyNames:
- "My Policy Name"
# Application settings
autoRedirectToIdentity: false
enableBindingCookie: false
httpOnlyCookieAttribute: true
sameSiteCookieAttribute: "lax" # none, lax, strict
logoUrl: ""
skipInterstitial: false
appLauncherVisible: true
serviceAuth401Redirect: false # Return 401 instead of browser redirect for M2M consumers
customDenyMessage: ""
customDenyUrl: ""
customNonIdentityDenyUrl: ""
# Service Token configuration
serviceToken:
enabled: false
duration: "8760h" # 1 year
# DNS-only record (no tunnel) — supply target IP via cfzt.cloudflare.com/dnsIp annotation per route
dnsOnly:
enabled: false
proxied: false
ttl: 120
# staticIp: "192.168.1.50" # optional; prefer per-route annotation for flexibility- url (string): Origin service URL (e.g.,
https://my-ingress.namespace.svc:443) - httpRedirect (bool, default: true): Auto-redirect HTTP to HTTPS at Cloudflare edge
- originTLS (object): TLS configuration for HTTPS origins
- noTLSVerify (bool, default: false): Skip certificate verification
- originServerName (string): Custom SNI hostname
- caPool (string): Path to CA certificate file
- tlsTimeout (int, 1-300, default: 10): TLS handshake timeout in seconds
- http2Origin (bool, default: false): Use HTTP/2 for origin
- matchSNIToHost (bool, default: false): Match SNI to Host header
- enabled (bool, default: false): Create Cloudflare Access Application
- sessionDuration (string, default: "24h"): Session duration (e.g., "24h", "8h", "30m")
- existingPolicyNames (array of strings): Cloudflare Access policy names — operator resolves to UUIDs at reconcile time
- autoRedirectToIdentity (bool, default: false): Auto-redirect to IdP login
- appLauncherVisible (bool, default: true): Show in Cloudflare App Launcher
- skipInterstitial (bool, default: false): Skip interstitial page
- serviceAuth401Redirect (bool, default: false): Return HTTP 401 instead of browser redirect — use for M2M/API consumers
- httpOnlyCookieAttribute (bool, default: true): Set HttpOnly cookie flag
- sameSiteCookieAttribute (string): Cookie SameSite value (
none,lax,strict)
- enabled (bool, default: false): Create service token for machine-to-machine auth
- duration (string, default: "8760h"): Token lifetime
- enabled (bool, default: false): Create DNS A record only — no tunnel or Access Application
- proxied (bool, default: false): Proxy traffic through Cloudflare
- ttl (int, default: 120): DNS TTL in seconds
- staticIp (string, optional): Target IP address; alternatively use
cfzt.cloudflare.com/dnsIpannotation per route. Must be RFC 1918 private address.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: my-app
annotations:
cfzt.cloudflare.com/enabled: "true"
cfzt.cloudflare.com/hostname: "my-app.example.com"
cfzt.cloudflare.com/tenant: "my-tenant"
cfzt.cloudflare.com/template: "protected-adauth"
spec:
parentRefs:
- name: default
namespace: my-app
hostnames:
- "my-app.example.com"
rules:
- backendRefs:
- name: my-app
port: 8080See examples-httproutes.md for more complete examples including annotation-only and DNS-only routes.
apiVersion: cfzt.cloudflare.com/v1alpha1
kind: CloudflareZeroTrustTenant
metadata:
name: my-tenant
namespace: cloudflare-zero-trust-operator
spec:
accountId: "0123456789abcdef0123456789abcdef"
tunnelId: "12345678-1234-1234-1234-123456789abc"
zoneId: "0123456789abcdef0123456789abcdef" # optional
credentialRef:
name: cloudflare-api-token
key: tokenSpec Fields:
- accountId (required): Cloudflare Account ID
- tunnelId (required): Cloudflare Tunnel ID
- zoneId (optional): Cloudflare Zone ID (for DNS/zone-specific operations)
- credentialRef (required): Reference to a Kubernetes Secret containing the Cloudflare API token
- name: Secret name
- key: Key within the secret containing the API token
Route defaults (origin URL, TLS settings, Access settings) are configured in a base template named base-<tenant-name> rather than on the tenant CR. See Configuration Hierarchy.
See example-tenant.md for a complete example.
status:
observedGeneration: 1
lastSyncTime: "2026-02-18T10:00:00Z"
conditions:
- type: Ready
status: "True"
lastTransitionTime: "2026-02-18T10:00:00Z"
reason: ReconcileSuccess
message: "Successfully reconciled"
summary:
managedHTTPRoutes: 5
hostnameRoutes: 5
accessApplications: 3
accessPolicies: 4
serviceTokens: 2Create/Update Route
PUT https://api.cloudflare.com/client/v4/accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations
Body includes hostname routes configuration.
Create Application
POST https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps
Update Application
PUT https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps/{app_id}
Delete Application
DELETE https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps/{app_id}
Create Policy
POST https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps/{app_id}/policies
Update Policy
PUT https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps/{app_id}/policies/{policy_id}
Delete Policy
DELETE https://api.cloudflare.com/client/v4/accounts/{account_id}/access/apps/{app_id}/policies/{policy_id}
Create Service Token
POST https://api.cloudflare.com/client/v4/accounts/{account_id}/access/service_tokens
Delete Service Token
DELETE https://api.cloudflare.com/client/v4/accounts/{account_id}/access/service_tokens/{token_id}
- Kubernetes cluster (1.23+)
- Any Gateway API-compatible ingress controller
- Cloudflare account with Zero Trust enabled
- Cloudflare API token with permissions:
- Account.Cloudflare Tunnel:Edit
- Account.Access:Edit
- Create Cloudflare API token secret:
kubectl create secret generic cloudflare-api-token \
--from-literal=token=YOUR_API_TOKEN \
-n cloudflare-zero-trust- Install CRDs:
# Install all CRDs
kubectl apply -f config/crd/cfzt.cloudflare.com_cloudflarezerotrusttenants.yaml
kubectl apply -f config/crd/cfzt.cloudflare.com_cloudflarezerotrusttemplates.yaml
kubectl apply -f config/crd/cfzt.cloudflare.com_cloudflarezerotrustoperatorconfigs.yaml- Install RBAC and operator:
kubectl apply -f config/rbac/
kubectl apply -f config/deployment/operator.yaml- Install example templates (optional — installed automatically if using Helm with
exampleTemplates.install: true):
# Helm (recommended) — example templates are included and enabled by default
helm install cloudflare-zero-trust-operator ./charts/cloudflare-zero-trust-operator
# Or apply manually
kubectl apply -f charts/cloudflare-zero-trust-operator/templates/example-templates.yamlSee examples-templates.md for template definitions and examples-httproutes.md for annotated HTTPRoute examples.
-
Create a CloudflareZeroTrustTenant:
See example-tenant.md for a complete example including the required
base-<tenant-name>template. -
Annotate your HTTPRoutes:
kubectl annotate httproute my-app \
cfzt.cloudflare.com/enabled="true" \
cfzt.cloudflare.com/hostname="myapp.example.com" \
cfzt.cloudflare.com/tenant="my-tenant" \
cfzt.cloudflare.com/template="protected-adauth"Environment variables for the operator:
| Variable | Default | Description |
|---|---|---|
WATCH_NAMESPACES |
"" (all) |
Comma-separated namespaces to watch |
POLL_INTERVAL_SECONDS |
60 |
Reconciliation loop interval |
LOG_LEVEL |
INFO |
Log level (DEBUG, INFO, WARNING, ERROR) |
CLOUDFLARE_API_BASE |
https://api.cloudflare.com/client/v4 |
Cloudflare API base URL |
- Python 3.9+
- kubectl configured with cluster access
- Install dependencies:
pip install -r container/requirements.txt- Set up test environment:
export KUBECONFIG=~/.kube/config
export WATCH_NAMESPACES="default"
export POLL_INTERVAL_SECONDS="30"
export LOG_LEVEL="DEBUG"Build container locally:
docker build -f container/Dockerfile -t ghcr.io/wheetazlab/cloudflare-zero-trust-operator:latest .Deploy to kind cluster:
kubectl apply -f config/crd/
kubectl apply -f config/rbac/
kubectl apply -f config/deployment/The operator is designed to be idempotent:
- Repeated reconciliation produces the same result
- Cloudflare resources are updated in place when configuration changes
- Resource IDs stored in annotations enable proper updates
When an HTTPRoute is deleted or annotations are removed:
- Operator detects the removal during next reconciliation
- Retrieves Cloudflare resource IDs from stored state (if annotations still exist)
- Calls Cloudflare APIs to delete:
- Service tokens (and associated Secrets)
- Access policies
- Access applications
- Hostname routes
- Removes state annotations from HTTPRoute (if still exists)
The operator uses Kubernetes finalizers on managed HTTPRoutes to ensure clean deletion of Cloudflare resources before the HTTPRoute is removed from etcd.
The operator is designed to support additional Cloudflare Zero Trust features:
- Identity Provider Integrations: Manage IdP configurations via annotations
- Device Posture Checks: Define device posture requirements
- Gateway Policies: DNS and HTTP filtering rules
- DLP Profiles: Data Loss Prevention configurations
- Audit Logs: Export audit logs to Kubernetes events or external systems
To add support for new Cloudflare resources:
- Extend annotation schema in docs
- Add API interaction functions in
python/cloudflare_api.py - Update reconciliation logic in
python/reconciler.py - Add state tracking annotations
- Update examples
kubectl logs -n cloudflare-zero-trust deployment/cloudflare-zero-trust-operator -fkubectl get cloudflarezerotrusttenants -o yamlkubectl get httproute my-app -o jsonpath='{.metadata.annotations}' | jqIssue: Operator not detecting HTTPRoute changes
- Solution: Check WATCH_NAMESPACES includes the HTTPRoute namespace
- Solution: Verify HTTPRoute has
cfzt.cloudflare.com/enabled: "true"
Issue: Cloudflare API rate limiting
- Solution: Increase POLL_INTERVAL_SECONDS
- Solution: Reduce number of managed HTTPRoutes per tenant
Issue: Access Application not created
- Solution: Verify
cfzt.cloudflare.com/accessApp: "true"annotation - Solution: Check Cloudflare API token has Access:Edit permission
- Solution: Review operator logs for API errors
- API Tokens: Store in Kubernetes Secrets, never in annotations or ConfigMaps
- Service Token Secrets: Automatically created with generated names, use RBAC to restrict access
- RBAC: Operator requires read/write access to HTTPRoutes and tenant CRs, read access to Secrets
- Namespace Isolation: Use separate tenants per namespace for multi-tenancy
MIT License - see LICENSE file for details