Apex Redirects, external-dns, and a Kubernetes Operator for Cloudflare Page Rules

Share
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-cname flag 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:

  1. Create, update, and resume — kopf handlers fire when a CR is created, updated, or when the operator pod restarts and reprocesses existing CRs.
  2. Periodic timer — a @kopf.timer handler re-runs the full reconcile loop for every existing CR at a configurable interval (default: 300 seconds). This is the drift detection mechanism.
  3. Delete — a @kopf.on.delete handler 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:

  1. The paulbramhall-uk app repo declares the apex redirect in deployment/environments/values.yaml via the redirectFrom block shown above. That single entry is all the consumer writes.
  2. ArgoCD syncs the paulbramhall-uk Application. The tardisio-service chart renders two CRs: a DNSEndpoint for the dummy A record and a CloudflareRule for the Page Rule. Both land in the paulbramhall-uk namespace.
  3. external-dns reconciles the DNSEndpoint CR and creates the dummy A record (192.0.2.1, proxied) at the apex in Cloudflare via the Cloudflare DNS API.
  4. The operator reconciles the CloudflareRule CR: resolves the zone ID via GET /zones?name=paulbramhall.uk, lists existing Page Rules via GET /zones/:zone_id/pagerules, and creates the Page Rule via POST /zones/:zone_id/pagerules (or updates it via PUT if it already exists and has drifted).
  5. 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 to https://www.paulbramhall.uk/$1.
  6. A @kopf.timer handler 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 via GET /zones?name=.
  • Page Rules:Edit — for Page Rule CRUD via POST, PUT, and DELETE on /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 apply pipeline, and has no k8s-side reconciliation loop.
  • Extending external-dns to manage Page Rules — rejected because external-dns is 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