Apex Redirects, external-dns, and a Kubernetes Operator for Cloudflare Page Rules
The apex of a domain (for example, paulbramhall.uk) needs to redirect to its canonical www counterpart (www.paulbramhall.uk) so that HSTS, cookies, and search indexing all converge on a single URL. On most platforms, this is a straightforward DNS or edge configuration task. On a Kubernetes cluster that uses external-dns to publish DNS records to Cloudflare, routes public traffic through a Cloudflare Tunnel, and sits on the Cloudflare Free plan, it turns into a more involved problem than you might expect.
To be clear about scope: this problem is specific to the combination of the Cloudflare Tunnel topology (where the path is Cloudflare Edge → Cloudflare Tunnel → in-cluster Ingress Controller) and the use of external-dns as the authoritative DNS manager. If you simply proxy an A record directly to an ingress IP, or manage DNS manually (where Cloudflare handles CNAME flattening transparently), this constraint does not bite. What follows applies specifically to the case where external-dns owns DNS publication, the tunnel owns routing, and the Cloudflare DNS API is the only programmatic surface available.
This post documents the implementation I built to solve it: a custom Kubernetes operator that reconciles CloudflareRule custom resources to Cloudflare Page Rules via the Cloudflare REST API. It covers why external-dns alone cannot deliver an apex redirect when routing through a Cloudflare Tunnel, why Page Rules are the correct mechanism on the Free plan, and how the two halves of the solution—a DNS half owned by the Helm chart and a routing half owned by the operator—compose at the Cloudflare edge.
A note on sources: the implementation lives across several private Git repositories in my home-lab platform. I cannot link directly to those repositories, so where I reference internal code or configuration I include the relevant snippets inline. Every external claim is backed by a link to the official documentation in the references section at the end.
The problem: CNAME at the apex
The cluster uses external-dns to publish DNS records to Cloudflare. Public services are routed through a Cloudflare Tunnel, which means the canonical DNS target for a public hostname is a CNAME to <tunnel-id>.cfargotunnel.com. For a subdomain like www.paulbramhall.uk, this works without issue: external-dns creates the CNAME record, Cloudflare proxies it, and the tunnel dials the in-cluster Traefik entrypoint.
This is where the topology matters. If public traffic were routed by a proxied A record pointing directly at an ingress IP, the apex would simply publish that A record. The problem arises because external-dns strictly enforces RFC 1034 §3.6.2 client-side before it ever calls the Cloudflare API.
RFC 1034 §3.6.2 specifies that a CNAME record cannot coexist with other record types at the same name. Because a zone apex almost always has other records (like MX or NS records), external-dns simply refuses to publish a CNAME there.
Ironically, Cloudflare's own edge does support CNAMEs at the apex through a feature called CNAME flattening. The Cloudflare DNS API happily accepts them. The constraint is not at the Cloudflare layer; it is entirely at the external-dns layer, which does not expose CNAME flattening as a primitive and drops the record before it leaves the cluster.
The external-dns alias annotation
external-dns defines an annotation called external-dns.alpha.kubernetes.io/alias that, when set to true, specifies that CNAME records generated by a resource should instead be alias records. The external-dns documentation lists the providers that support this annotation:
- AWS — relevant only when the
--aws-prefer-cnameflag is specified. - PowerDNS — creates ALIAS records when the annotation is set to
true.
Cloudflare is not listed as a supported provider. The annotation is passed through into the cloudflare-go SDK call, where it is silently ignored. There is no equivalent Cloudflare DNS API primitive for an "alias" record in the sense that AWS Route53 or PowerDNS implement one. The annotation is a Route53-origin concept; the external-dns Cloudflare provider does not read or act on it.
The net result is that the apex can never reach a stable published state through external-dns default CNAME-based rendering for tunnel-routed primaries. external-dns churns on the TXT ownership record indefinitely, and the apex CNAME is rejected on every reconcile cycle.
Patching external-dns to add this capability is not an option I pursued. The project is DNS-focused, and since it enforces RFC 1034 §3.6.2 internally, overriding that behaviour to rely on provider-specific CNAME flattening felt like fighting the tool's core design.
Why a dummy A record
The apex must resolve to something for any redirect mechanism to be reachable. Without a DNS record at the apex, clients receive NXDOMAIN, and no redirect ever fires.
The solution at the DNS layer is to publish a dummy A record at the apex using a reserved IP address. The Cloudflare Page Rules documentation explicitly recommends this pattern:
If you are creating a Page Rule for a hostname that does not have a real origin server, you still need a proxied DNS record. You can use a reserved IP address or domain as a placeholder. The record only needs to exist so that Cloudflare proxies traffic for that hostname.
The documentation cites RFC 5737 as the source for reserved IP addresses. The implementation uses 192.0.2.1, which is an IPv4 address reserved for documentation purposes by RFC 5737. The record is set to proxied: true, which routes the apex through Cloudflare's edge. The dummy IP is unreachable, but it does not need to be reachable—the Page Rule intercepts the request at the edge before any attempt to connect to the origin is made.
Why a Page Rule instead of a Worker
The redirect itself needs to be delivered at the Cloudflare edge. Cloudflare offers two mechanisms that can serve a redirect from the edge: Workers and Page Rules.
The initial implementation I built used Cloudflare Workers. A one-shot Kubernetes Job in the tardisio-service chart deployed a Worker script and a Worker route for the apex. At the time, I assumed Workers were the right tool for edge redirects — they are powerful, they run at the edge, and the Cloudflare API accepted both the script and the route without complaint.
Testing against the live paulbramhall.uk zone, which is on the Cloudflare Free plan, told a different story. The Worker script and route deployed successfully—the API returned HTTP 200 for both—but requests to the apex returned an error instead of the expected 301 redirect. I later discovered this is a hard platform constraint of the Free plan, not something that can be worked around.
Page Rules are a first-class feature on all Cloudflare plan tiers, including Free. The Cloudflare documentation states that Page Rules are available on the Free plan (with a limit of 3 rules), the Pro plan (20 rules), the Business plan (50 rules), and the Enterprise plan (125 rules). A Page Rule with a forwarding_url action can match a URL pattern at the apex and return a 301 or 302 redirect to any destination URL, with support for path-capture backreferences ($1, $2).
Therefore a decision was made to replace the Workers-based approach with a Page Rules-based approach. The one-shot Job template was removed from the chart, and the cloudflareworkers values block was renamed to cloudflarerules.
The resource types
Before getting into the operator itself, it helps to understand the Kubernetes resource types involved. There are three custom resources in play, plus the standard Helm chart primitives that render them.
DNSEndpoint
DNSEndpoint is a custom resource defined by external-dns. It is the declarative way to tell external-dns "create this DNS record in the provider". The CRD is owned by external-dns and is cluster-scoped. A DNSEndpoint CR contains a list of endpoints, each with a dnsName, a recordType, a list of targets, and optional providerSpecific key-value pairs that map to provider-specific features (like Cloudflare's proxied flag).
For the apex redirect, the chart renders a DNSEndpoint CR that looks like this (trimmed to the relevant fields):
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: paulbramhall-uk-7c503d4
namespace: paulbramhall-uk
spec:
endpoints:
- dnsName: paulbramhall.uk
recordType: A
targets:
- "192.0.2.1"
providerSpecific:
- name: external-dns.alpha.kubernetes.io/cloudflare-proxied
value: "true"
external-dns watches for DNSEndpoint CRs, reads the endpoints, and reconciles them into Cloudflare DNS records via the Cloudflare API. The cloudflare-proxied provider-specific entry tells external-dns to set the Cloudflare proxy flag on the record, which routes the apex through Cloudflare's edge.
CloudflareRule
CloudflareRule is a custom resource defined by the operator's own CRD (cf.tardis.io/v1alpha1). It is the declarative way to tell the operator "create this Cloudflare Page Rule". The CRD is cluster-scoped and is applied by the operator's own ArgoCD Application, not by the consuming app's chart. Any app can declare CRs of this type without owning the CRD.
For the apex redirect, the chart renders a CloudflareRule CR that looks like this:
apiVersion: cf.tardis.io/v1alpha1
kind: CloudflareRule
metadata:
name: ghostcms-apex-redirect-7c503d4
namespace: paulbramhall-uk
spec:
zone: "paulbramhall.uk"
target:
pattern: "paulbramhall.uk/*"
action:
type: "forwarding_url"
url: "https://www.paulbramhall.uk/$1"
statusCode: 301
The operator watches for CloudflareRule CRs cluster-wide, reads the spec, and reconciles each one into a Cloudflare Page Rule via the Cloudflare REST API.
The CRD/CR split
The CRD (the type system) and the CR instances (the actual declarations) live in different places. This is the same split that external-dns uses for DNSEndpoint: the DNSEndpoint CRD is owned by external-dns, but the DNSEndpoint CRs are declared by whatever app needs a DNS record. The CloudflareRule CRD is owned by the operator repo's local chart, and the CloudflareRule CRs are declared by consuming apps via the tardisio-service chart's cloudflarerules: values block.
This split means a single operator reconciles all Page Rules across all namespaces, and any app can declare a Page Rule without installing its own CRD or its own operator instance.
The operator
Page Rules are normally managed imperatively through the Cloudflare dashboard. That contradicts the GitOps posture of the rest of the platform, where every piece of state — applications, networking, DNS records, secrets — is declared in Git and reconciled by ArgoCD and kopf-based controllers. I was not willing to make an exception for one Page Rule.
The operator is a kopf-based Kubernetes controller that runs as a single-replica Deployment. It watches CloudflareRule CRs cluster-wide and reconciles each one to a Cloudflare Page Rule. The reconcile logic lives in a single Python file (reconcile.py).
The CRD spec
The CRD spec contains three required fields and several optional ones:
zone— the FQDN of the Cloudflare zone the Page Rule belongs to. The operator resolves this to a zone ID via the Cloudflare API on first encounter and caches it for the lifetime of the pod.target.pattern— the URL match pattern (for example,paulbramhall.uk/*).action.type— the Page Rule action type (for example,forwarding_url).
For forwarding_url and redirect_url actions, the spec also accepts action.url (the destination URL, supporting $1 backreferences for path capture) and action.statusCode (301 or 302, defaulting to 301). Optional fields include target.operator (matches or equals, defaulting to matches), priority (an integer, defaulting to 1), and enabled (a per-CR opt-out, defaulting to true).
The status subresource exposes cfRuleId (the Cloudflare-side Page Rule ID), cfZoneId (the resolved zone ID), priority (the priority actually used), and a conditions array with a Ready condition that reports whether the rule is synced.
The reconcile loop
The reconcile loop runs on three triggers:
- Create, update, and resume — kopf handlers fire when a CR is created, updated, or when the operator pod restarts and reprocesses existing CRs.
- Periodic timer — a
@kopf.timerhandler re-runs the full reconcile loop for every existing CR at a configurable interval (default: 300 seconds). This is the drift detection mechanism. - Delete — a
@kopf.on.deletehandler removes the Cloudflare-side Page Rule when the CR is deleted from the cluster.
The core reconcile function (_reconcile) performs the following steps:
def _reconcile(spec, name, generation, status=None):
status = status or {}
existing_conditions = status.get("conditions") or []
try:
cf = CloudflareClient.from_env()
zone_name = spec["zone"]
target = spec.get("target") or {}
target_pattern = target.get("pattern", "")
action = spec.get("action") or {}
action_type = action.get("type", "")
enabled = spec.get("enabled", True)
# 1. Resolve the zone ID (cached per-pod).
zone_id = cf.get_zone_id(zone_name)
# 2. Honour the per-CR opt-out.
if not enabled:
existing_rule_id = status.get("cfRuleId")
if existing_rule_id:
cf.delete_page_rule(zone_id, existing_rule_id)
return {
"cfRuleId": "",
"cfZoneId": zone_id,
"conditions": [_build_condition(
"Ready", "Unknown", "Disabled",
"spec.enabled is false; CF-side rule deleted.",
existing_conditions,
)],
}
# 3. List existing rules and find the one owned by this CR.
existing = cf.list_page_rules(zone_id)
owned = find_owning_rule(
existing, target_pattern, action_type, status.get("cfRuleId")
)
# 4. Build the desired rule body.
spec_priority = spec.get("priority")
if owned:
priority = owned.get("priority") or auto_allocate_priority(
existing, spec_priority
)
else:
priority = auto_allocate_priority(existing, spec_priority)
desired = build_rule_body(spec, priority)
# 5. Create or update (drift detection via rules_drifted()).
if owned is None:
new_id = cf.create_page_rule(zone_id, desired)
cf_rule_id = new_id
else:
cf_rule_id = owned.get("id", "")
if rules_drifted(owned, desired):
cf.update_page_rule(zone_id, cf_rule_id, desired)
# 6. Patch status.
return {
"cfRuleId": cf_rule_id,
"cfZoneId": zone_id,
"priority": priority,
"observedGeneration": generation,
"conditions": [_build_condition(
"Ready", "True", "RuleSynced",
f"cfRuleId={cf_rule_id}",
existing_conditions,
)],
}
except requests.HTTPError as e:
return _error_status(f"CF API HTTP error: {e}", generation, existing_conditions)
except Exception as e:
return _error_status(f"{type(e).__name__}: {e}", generation, existing_conditions)
The handler wrappers use patch.status.update(result) instead of returning a dict. This is a kopf-specific gotcha documented in the code: when a handler returns a dict, kopf stores it under status.<handler_id> (for example, status.reconcile-cloudflarerule). Because the CRD has a structural status schema, Kubernetes prunes that unknown key and the status never lands—the resource appears permanently out of sync. Patching status directly via the kopf Patch object writes to the top-level status fields the CRD expects.
Drift detection
The drift detection works as follows. The find_owning_rule function matches existing Cloudflare-side rules to CRs by the (target.pattern, action.type) triple, or by the cfRuleId stored in status if it is set (which is more reliable). The rules_drifted function compares the existing rule's targets, actions, and priority against the desired body:
def rules_drifted(existing, desired):
# Compare targets
if existing.get("targets") != desired.get("targets"):
return True
# Compare actions (order-insensitive on the id field)
existing_actions = existing.get("actions") or []
desired_actions = desired.get("actions") or []
if len(existing_actions) != len(desired_actions):
return True
for ea, da in zip(existing_actions, desired_actions):
if ea.get("id") != da.get("id"):
return True
if ea.get("value") != da.get("value"):
return True
# Compare priority
if existing.get("priority") != desired.get("priority"):
return True
return False
If a Page Rule is deleted manually in the Cloudflare dashboard, find_owning_rule returns None on the next timer tick, and _reconcile recreates it via POST. If a Page Rule's target pattern, action URL, or priority is changed manually, rules_drifted returns True, and _reconcile repairs it via PUT. This mirrors the behaviour of external-dns, which periodically reconciles DNS records to repair manual changes.
The operator does not delete rules it did not create. The list step returns all active Page Rules in the zone, but find_owning_rule only matches rules that correspond to a CR. Rules that no CR owns are left alone—this avoids clobbering manually-created rules in the same zone.
Rate limiting and retries
The Cloudflare API has a global rate limit of 1,200 requests per five minutes, as documented in the Cloudflare API reference. The operator's reconcile loop is well under this. The operator handles HTTP 429 (rate limited) and HTTP 5xx responses with exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s, with a maximum total wait of 5 minutes per request. HTTP 4xx responses (other than 429) are not retried; the operator sets Ready=False with the Cloudflare error code as the reason.
The retry wrapper is straightforward:
RETRY_BACKOFF = [1, 2, 4, 8, 16, 32, 60]
MAX_RETRY_TIME = 300 # 5 minutes total
def _with_retry(fn, *args, **kwargs):
last_exc = None
for attempt, delay in enumerate([0] + RETRY_BACKOFF):
if delay > 0:
time.sleep(delay)
try:
return fn(*args, **kwargs)
except requests.HTTPError as e:
status_code = e.response.status_code if e.response is not None else 0
if status_code == 429 or status_code >= 500:
last_exc = e
continue
raise # 4xx (non-429): do not retry
except (requests.ConnectionError, requests.Timeout) as e:
last_exc = e
continue
raise last_exc
The chart integration
The tardisio-service chart renders CloudflareRule CR instances from a cloudflarerules: values block. The CRD itself is not rendered by this chart—it is owned by the operator repo's local chart and applied by the operator's ArgoCD Application. Any app repo can declare CRs of this type without owning the CRD; the operator reconciles them cluster-wide.
The values block
The schema for the cloudflarerules block:
cloudflarerules:
apex-redirect:
zone: paulbramhall.uk
target:
pattern: paulbramhall.uk/*
action:
type: forwarding_url
url: https://www.paulbramhall.uk/$1
statusCode: 301
The CR's metadata.name is content-addressed via the chart's dnsEndpointName helper. The inputs to the hash are the CR key, the literal string rule as the record type, the zone as the target, the literal string cf as the ingress class, and the literal string cloudflare as the provider. This produces names like apex-redirect-7c503d4. Content-addressed naming means that editing the action URL does not rename the CR—ArgoCD sees a no-op on the resource name and only updates the spec, which avoids prune-and-recreate churn on every values change.
The redirectFrom abstraction
For the apex redirect case specifically, the chart auto-generates both halves when redirectFrom includes the "." key (the apex) on a public ingress. The consumer does not need to manually declare a dnsEndpoint override or a cloudflarerules entry—the chart derives both from the redirectFrom block.
The actual values for paulbramhall.uk (from the app repo's deployment/environments/values.yaml) are surprisingly minimal:
ingresses:
ghostcms:
enabled: true
visibility: public
subdomain: www
redirectFrom:
".":
mode: 301
That single redirectFrom entry is all the consumer needs to declare. The chart's dnsendpoints-from-ingresses.yaml template renders the DNSEndpoint CR for the dummy A record, and the cloudflarerule.yaml template renders the CloudflareRule CR for the Page Rule.
What the chart renders
For the apex case, the chart's dnsendpoints-from-ingresses.yaml template contains logic that detects when the redirect source is the zone apex on a public ingress and automatically emits a dummy A record instead of the default CNAME:
{{- else if and (eq $redirectHost $apexHost) (eq $dns.visibilityType "public") -}}
{{- /*
Auto-emit a dummy A record at the apex for public apex
redirects. RFC 1034 §3.6.2 forbids CNAME at the zone
apex, and external-dns's Cloudflare provider rejects a
true CNAME at the apex with error 4004. We therefore
emit a dummy A record (RFC 5737 192.0.2.1) proxied
through Cloudflare so a Page Rule (auto-generated as a
CloudflareRule CR) fires at the edge. The IP is
irrelevant -- the Page Rule short-circuits every
request before it resolves.
A consumer can override by setting `dnsEndpoint` explicitly.
*/ -}}
{{- $redirectRecordType = "A" -}}
{{- $redirectTarget = "192.0.2.1" -}}
{{- $redirectProxied = "true" -}}
The cloudflarerule.yaml template contains the corresponding logic for the Page Rule half. When the redirect source is the apex on a public ingress, it auto-generates a CloudflareRule CR with a forwarding_url action:
{{- if eq $redirectHost $apexHost }}
{{- $redirectMode := dig "mode" 301 $redirectCfg | toString -}}
{{- $crKey := printf "%s-apex-redirect" $ingressName -}}
{{- $crName := include "tardisio-service.dnsEndpointName" (dict ...) -}}
{{- /* Skip if the consumer already declared a manual entry
with the same content-addressed name. */ -}}
{{- if not $manualExists }}
---
apiVersion: cf.tardis.io/v1alpha1
kind: CloudflareRule
metadata:
name: {{ $crName }}
namespace: {{ $.Release.Namespace }}
spec:
zone: {{ $domain | quote }}
target:
pattern: {{ printf "%s/*" $apexHost | quote }}
action:
type: "forwarding_url"
url: {{ printf "https://%s/$1" $dns.host | quote }}
statusCode: {{ $redirectMode }}
{{- end -}}
The auto-generated CR is emitted only when all of the following are true: the ingress is visibility: public (Page Rules only fire at the Cloudflare edge), the redirect source host equals the zone apex, and the consumer has not already declared a manual cloudflarerules entry with the same content-addressed name (manual entries take precedence; auto-generation is a convenience, not an override).
Why the public apex skips the in-cluster redirect
The chart does not render an in-cluster Traefik Middleware or Ingress for the public apex case. The redirect is handled entirely at the Cloudflare edge. Rendering an in-cluster redirect on top of the edge redirect would create two competing 301s—the Cloudflare edge fires first, then Traefik would fire if the request reached it—and would require a real certificate and load balancer path for a hostname that is a Cloudflare-only proxy.
Non-apex redirects (for example, root.paulbramhall.uk to www.paulbramhall.uk) keep the in-cluster path. The Cloudflare edge only intercepts the apex; a redirect from a subdomain still needs Traefik to terminate it. Internal apex redirects also keep the in-cluster path, because there is no Cloudflare edge to intercept internal traffic.
The two halves compose
The DNS half and the routing half are independently owned and independently idempotent:
| Half | Owner | Purpose |
|---|---|---|
| DNS | Chart's redirectFrom.<key>.dnsEndpoint override |
Publishes a dummy A record at the apex, proxied: true |
| Routing | Operator's CloudflareRule CR |
Publishes a Page Rule that matches the apex and 301s to www |
Without the DNS half, the apex has no A record. Cloudflare does not answer for paulbramhall.uk, and the Page Rule never fires. The redirect is unreachable.
Without the routing half, the apex resolves to 192.0.2.1 (a documentation IP). Cloudflare proxies the request to nowhere. The user sees a 522 or 523 error.
With both, the apex resolves, Cloudflare's edge serves the request, the Page Rule fires, and the user receives a 301 redirect to the canonical www hostname.
Both halves use content-addressed resource naming via the same dnsEndpointName helper, so re-applying the same values is a no-op for both external-dns and the operator. Editing one half (for example, changing the Page Rule's destination URL) does not affect the other half's resource name. The two halves can be edited in separate commits without churn.
The end-to-end path
The full path for the paulbramhall.uk apex redirect:
- The
paulbramhall-ukapp repo declares the apex redirect indeployment/environments/values.yamlvia theredirectFromblock shown above. That single entry is all the consumer writes. - ArgoCD syncs the paulbramhall-uk Application. The
tardisio-servicechart renders two CRs: aDNSEndpointfor the dummy A record and aCloudflareRulefor the Page Rule. Both land in thepaulbramhall-uknamespace. external-dnsreconciles theDNSEndpointCR and creates the dummy A record (192.0.2.1, proxied) at the apex in Cloudflare via the Cloudflare DNS API.- The operator reconciles the
CloudflareRuleCR: resolves the zone ID viaGET /zones?name=paulbramhall.uk, lists existing Page Rules viaGET /zones/:zone_id/pagerules, and creates the Page Rule viaPOST /zones/:zone_id/pagerules(or updates it via PUT if it already exists and has drifted). - Requests to
paulbramhall.uk/*hit the Cloudflare edge. The proxied A record ensures Cloudflare handles the request. The Page Rule fires and returns a 301 tohttps://www.paulbramhall.uk/$1. - A
@kopf.timerhandler re-reconciles every CR every 300 seconds to repair manual Cloudflare-side deletions or modifications.
The data flow is unidirectional: Git → Kubernetes → Cloudflare. There is no imperative step anywhere in the path.
Verifying the result
Once the operator has reconciled the CR, the redirect is verifiable with a single curl:
$ curl -I https://paulbramhall.uk/
HTTP/2 301
server: cloudflare
location: https://www.paulbramhall.uk/
cf-ray: 8a7b6c5d4e3f2100-LHR
The Server: cloudflare header confirms the redirect is served by the Cloudflare edge, not by Traefik. The cf-ray header identifies the specific Cloudflare data centre that served the request. A request with a path component preserves the path through the $1 backreference:
$ curl -I https://paulbramhall.uk/some/deep/path
HTTP/2 301
server: cloudflare
location: https://www.paulbramhall.uk/some/deep/path
On the Kubernetes side, the CR's status reflects the Cloudflare-side state:
$ kubectl -n paulbramhall-uk get cloudflarerule -o yaml
apiVersion: cf.tardis.io/v1alpha1
kind: CloudflareRule
metadata:
name: ghostcms-apex-redirect-7c503d4
namespace: paulbramhall-uk
spec:
zone: "paulbramhall.uk"
target:
pattern: "paulbramhall.uk/*"
action:
type: "forwarding_url"
url: "https://www.paulbramhall.uk/$1"
statusCode: 301
status:
cfRuleId: "some-rule-id"
cfZoneId: "some-zone-id"
priority: 1
conditions:
- type: Ready
status: "True"
reason: RuleSynced
message: "cfRuleId=some-rule-id"
The Ready condition reports True with reason RuleSynced, and the cfRuleId field contains the actual Cloudflare-side Page Rule ID. If the Page Rule is deleted in the Cloudflare dashboard, the next timer tick recreates it and the condition returns to True.
CF API token scopes
The operator mounts the existing cloudflare-dns-token Secret from the external-dns namespace via a secretKeyRef environment variable (CF_API_TOKEN). The token requires two scopes on every zone in scope:
Zone:Read— for the zone ID resolution viaGET /zones?name=.Page Rules:Edit— for Page Rule CRUD viaPOST,PUT, andDELETEon/zones/:id/pagerules.
No other scopes are needed. The operator does not touch DNS records (no Zone:DNS:Edit), does not use Workers (no Workers Scripts:Edit or Workers Routes:Edit), and does not need account-level access (no Account:Read). Token rotation is performed manually through the Cloudflare dashboard, and the new token is re-encrypted into the cluster's SOPS-managed Secret store.
The token is read from the environment in the CloudflareClient.from_env factory:
@classmethod
def from_env(cls) -> "CloudflareClient":
token = os.environ.get("CF_API_TOKEN", "").strip()
if not token:
path = Path(os.environ.get("CF_API_TOKEN_FILE", DEFAULT_TOKEN_PATH))
if not path.exists():
raise RuntimeError(
f"CF API token not found. Set CF_API_TOKEN env var or "
f"mount a token file at {path}."
)
token = path.read_text().strip()
s = requests.Session()
s.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
})
return cls(token=token, session=s)
The Authorization: Bearer header is set once on the session and reused for all subsequent API calls. The token value itself never lives in Git—it is SOPS-encrypted with Age and applied to the cluster by ArgoCD.
What was rejected
As well as Workers, I looked at a few other possibilities before settling on the Page Rules approach:
- Cloudflare Workers — rejected because Worker routes on custom hostnames did not function on the Free plan during testing.
- Imperative Page Rule creation via the Cloudflare dashboard — rejected because it is imperative, has no audit trail, has no drift detection, and prone to human error.
- Terraform-managed Page Rules — rejected primarily because I do not use Terraform for any of the platform, and because the Terraform state file would be out-of-band from ArgoCD, would require a separate
terraform applypipeline, and has no k8s-side reconciliation loop. - Extending
external-dnsto manage Page Rules — rejected becauseexternal-dnsis DNS-focused, and Page Rules are a routing concern with different RBAC, scope, and release cadence. - Doing nothing and treating the Page Rule as out-of-band platform config — rejected because the rest of the platform is GitOps-managed.
Wrapping up
The apex redirect problem is one of those things that sounds like it should be a single configuration line and turns out to be a small distributed system. The constraint stack — RFC 1034 at the DNS layer, external-dns enforcing it client-side, the Free plan not supporting Worker routes on custom hostnames — means there is no single tool that solves it declaratively.
The final solution splits the problem into two halves that compose at the Cloudflare edge: a dummy A record (owned by the chart, reconciled by external-dns) and a Page Rule (owned by a custom operator, reconciled via the Cloudflare REST API). Both halves are declared in Git, both are content-addressed so re-applying the same values is a no-op, and both have drift detection—external-dns for the DNS record, the operator's timer handler for the Page Rule.
The consumer-facing API is a single redirectFrom entry in a Helm values file. Everything else — the DNSEndpoint CR, the CloudflareRule CR, the dummy IP, the proxied flag, the Page Rule pattern, the 301 status code, the path backreference — is derived by the chart and reconciled by the controllers. That is the part I am happiest with: the complexity lives in the platform, not in the application manifest. The person deploying the blog never has to think about CNAME flattening, RFC 1034, or Page Rule APIs — they just write mode: 301 and the platform handles the rest.
References
- RFC 1034 §3.6.2 — CNAME records cannot coexist with other record types at the same name.
- RFC 5737 — IPv4 addresses reserved for documentation (
192.0.2.0/24). - Cloudflare CNAME flattening documentation — CNAME flattening allows a CNAME at the zone apex; it is an edge-side behaviour.
- Cloudflare Page Rules documentation — Page Rules are available on all plans including Free; Page Rules require a proxied DNS record; reserved IP addresses are recommended for placeholder records.
- external-dns annotations documentation — the
external-dns.alpha.kubernetes.io/aliasannotation is supported on AWS and PowerDNS; Cloudflare is not listed. - external-dns Cloudflare tutorial — the Cloudflare provider submits DNS record changes through the Cloudflare Batch DNS Records API.
- Cloudflare API rate limits — global rate limit of 1,200 requests per five minutes.
- kopf — Kubernetes Operator Pythonic Framework — the Python library used to implement the controller.