Skip to content

cfgate.iocfgate v0.2.0-alpha.11 · Release documentation

CloudflareDNS

Manages DNS record synchronization independently from CloudflareTunnel resources.

API Version: cfgate.io/v1alpha1 Kind: CloudflareDNS Short Names: cfdns, dns Scope: Namespaced

CloudflareDNS manages DNS record synchronization for Cloudflare zones. It supports two target modes: tunnel references (for tunnel-based CNAME records) and external targets (for non-tunnel DNS records such as A, AAAA, or external CNAMEs). DNS records can be sourced automatically from Gateway API HTTPRoute resources or explicitly defined in the spec.

CloudflareDNS verifies resource-specific ownership markers on data and TXT records before mutation. Cloudflare DNS writes do not support compare-and-swap, so competing installations still require external coordination. Lifecycle behavior is controlled via spec.policy (sync, upsert-only, create-only) and spec.cleanupPolicy.

When using tunnelRef, credentials are inherited from the referenced CloudflareTunnel. When using externalTarget, the cloudflare field must be provided explicitly.

FieldTypeDefaultRequiredDescription
spec.tunnelRef.namestringnoneYes (if tunnelRef set)Name of the CloudflareTunnel resource. 1-63 chars.
spec.tunnelRef.namespacestring(resource namespace)NoNamespace of the CloudflareTunnel. Max 63 chars.
spec.externalTarget.typeRecordTypenoneYes (if externalTarget set)DNS record type: CNAME, A, or AAAA.
spec.externalTarget.valuestringnoneYes (if externalTarget set)Target value: domain name for CNAME, IP address for A/AAAA. 1-253 chars.
spec.zones[][]DNSZoneConfignoneYesDNS zones to manage. Min 1, max 10.
spec.zones[].namestringnoneYesZone domain name (e.g., example.com). 1-253 chars.
spec.zones[].idstringnoneNoExplicit Cloudflare zone ID. When provided, skips API zone lookup. Max 32 chars.
spec.zones[].proxied*bool(inherits from spec.defaults.proxied)NoPer-zone proxy override. true enables Cloudflare proxy (orange cloud), false DNS-only. nil inherits from spec.defaults.proxied.
spec.policyDNSPolicysyncNoDNS record lifecycle policy. One of: sync, upsert-only, create-only.
spec.source.gatewayRoutesDNSGatewayRoutesSourcenoneNoEnables route discovery when the block is present. Explicit-only resources omit this block and do not watch routes.
spec.source.gatewayRoutes.enabledbooltrueNoEnables automatic hostname discovery from Gateway API routes.
spec.source.gatewayRoutes.annotationFilterstringnoneNoOnly sync routes matching this annotation (key=value format). Max 255 chars.
spec.source.gatewayRoutes.namespaceSelector.matchLabelsmap[string]stringnoneNoSelect namespaces by label. Max 10 entries. At least one of matchLabels or matchNames required when namespaceSelector is set.
spec.source.gatewayRoutes.namespaceSelector.matchNames[]stringnoneNoSelect namespaces by name. Max 50 items. At least one of matchLabels or matchNames required when namespaceSelector is set.
spec.source.explicit[][]DNSExplicitHostnamenoneNoExplicitly defined hostnames to sync. Max 100 items.
spec.source.explicit[].hostnamestringnoneYesDNS hostname to create. 1-253 chars.
spec.source.explicit[].targetstring(resource-level resolved target)NoPer-hostname target override. Supports {{ .TunnelDomain }} when using tunnelRef. Max 253 chars.
spec.source.explicit[].proxied*bool(inherits from zone or defaults)NoPer-hostname Cloudflare proxy setting. nil inherits from zone then defaults.
spec.source.explicit[].ttlint32inheritedNoDNS record TTL in seconds. 1 = auto (Cloudflare-managed, typically 300s). Explicit range: 60-86400.
spec.defaults.proxiedbooltrueNoDefault Cloudflare proxy setting for all records.
spec.defaults.ttlint321NoDefault DNS record TTL. 1 = auto. Explicit range: 60-86400.
spec.ownership.ownerIdstringnoneNoDeprecated legacy hint; cannot override status.ownerId or authorize adoption. Retained in the schema for compatibility.
spec.ownership.txtRecord.enabled*booltrue (nil defaults to true)NoEnables TXT record-based ownership tracking.
spec.ownership.txtRecord.prefixstring_cfgateNoImmutable prefix for TXT record names. Max 63 chars.
spec.ownership.comment.enabledboolfalseNoDeprecated since v0.1.0-alpha.13. Ignored; the controller writes an exact owner marker. Schema removal is deferred to a future cleanup.
spec.ownership.comment.templatestringmanaged by cfgateNoDeprecated since v0.1.0-alpha.13. Ignored; the controller writes cfgate/owner=<owner-id>. Schema removal is deferred to a future cleanup.
spec.cleanupPolicy.deleteOnRouteRemoval*booltrue (nil defaults to true)NoDelete DNS records when the source route is deleted.
spec.cleanupPolicy.deleteOnResourceRemoval*booltrue (nil defaults to true)NoDelete DNS records when the CloudflareDNS resource itself is deleted (finalizer cleanup).
spec.cleanupPolicy.onlyManaged*booltrue (nil defaults to true)NoRetained for compatibility; ownership checks always apply, including when false.
spec.cloudflare.accountIdstringnoneNoCloudflare Account ID. Required when using externalTarget. Inherited from tunnel when using tunnelRef. Max 32 chars.
spec.cloudflare.accountNamestringnoneNoCloudflare Account name (resolved via API). Max 255 chars.
spec.cloudflare.secretRef.namestringnoneYes (if cloudflare set)Name of the credentials Secret. 1-253 chars.
spec.cloudflare.secretRef.namespacestring(resource namespace)NoNamespace of the credentials Secret. Max 63 chars.
spec.cloudflare.secretKeys.apiTokenstringCLOUDFLARE_API_TOKENNoKey name within the Secret for the API token. Max 253 chars.
spec.fallbackCredentialsRef.namestringnoneYes (if fallbackCredentialsRef set)Name of the fallback credentials Secret. 1-253 chars.
spec.fallbackCredentialsRef.namespacestring(resource namespace)NoNamespace of the fallback credentials Secret. Max 63 chars.

These are mutually exclusive. Exactly one must be specified.

tunnelRef: References a CloudflareTunnel resource. DNS CNAME records are created pointing to the tunnel’s domain ({tunnelId}.cfargotunnel.com). The controller waits for the tunnel to become ready before creating DNS records. When using tunnelRef, Cloudflare API credentials are inherited from the tunnel, so no separate spec.cloudflare is needed.

externalTarget: Points DNS records to an external resource. Supports CNAME (external domain), A (IPv4 address), and AAAA (IPv6 address) record types. When using externalTarget, spec.cloudflare must be provided since there is no tunnel to inherit credentials from.

# Tunnel-backed DNS
spec:
tunnelRef:
name: prod-tunnel
# External CNAME
spec:
externalTarget:
type: CNAME
value: external-lb.example.com
cloudflare:
accountId: "a1b2c3d4..."
secretRef:
name: cloudflare-api-token
# External A record
spec:
externalTarget:
type: A
value: "203.0.113.10"
cloudflare:
accountId: "a1b2c3d4..."
secretRef:
name: cloudflare-api-token

Defines the Cloudflare DNS zones where records are managed. Configure 1 to 10 zones. Each hostname uses the most specific configured zone, matching complete DNS labels and ignoring case and a trailing dot. For example, api.team.example.com uses team.example.com when both that zone and example.com are configured. An apex hostname matches its own zone; badexample.com does not match example.com.

Configure the zones that Cloudflare actually manages, including any separately delegated child zones. cfgate does not infer zone boundaries from registrable domains. Your API token must permit access to the selected zone. See Cloudflare subdomain setup for provider requirements.

id (optional): When provided, the controller uses this zone ID directly and skips the API zone lookup. This avoids the extra API call and is useful when the token does not have zone-list permissions or when you want to pin a specific zone ID.

proxied (optional): Per-zone override for the Cloudflare proxy setting. When nil, inherits from spec.defaults.proxied. Set to true for orange-cloud (Cloudflare proxy), false for DNS-only (grey-cloud).

spec:
zones:
- name: example.com
id: "zone123abc" # skip API lookup
proxied: true # force proxy on
- name: internal.dev
proxied: false # DNS-only for this zone

Controls the DNS record lifecycle policy. Aligned with external-dns patterns.

PolicyCreateUpdateDeleteUse Case
sync (default)YesYesYesFull lifecycle management. Records match desired state exactly.
upsert-onlyYesYesNoPrevents accidental deletion. Records are created and updated but never removed.
create-onlyYesNoNoImmutable records. Created once, never modified or deleted by the controller.
spec:
policy: upsert-only

Automatic DNS publication requires a cfgate-managed Gateway, permission for its tunnel reference, an admitted parent/listener attachment, an intersecting hostname, and valid effective origin transport settings. Namespace and annotation selectors further restrict discovery; they do not grant publication authority. Backend availability is separate: an admitted route may retain DNS while its backend returns an error. Explicit hostnames remain administrator-managed.

Configures automatic hostname discovery from Gateway API HTTPRoute resources. Route discovery is enabled by the presence of this block. If source.gatewayRoutes is absent, the resource is explicit-only and does not watch routes. If the block is present and enabled is omitted, it defaults to true.

annotationFilter: An opt-in filter that restricts which routes trigger DNS sync. The controller checks this annotation on HTTPRoute resources, never on Gateways. You can use any annotation key=value pair; adding, changing, or removing it triggers discovery and cleanup without waiting for the periodic reconciliation. The format is key=value. See Annotations Reference for details on how annotation filtering works.

A common convention is cfgate.io/dns-sync=enabled, but this is not a controller-defined annotation; it is a user-chosen convention. The controller simply checks whether the route has the specified annotation with the specified value.

namespaceSelector: Limits route discovery to specific namespaces. Supports matchLabels (label selectors) and matchNames (explicit namespace names). At least one must be specified when namespaceSelector is set. This enables multi-tenant setups where different CloudflareDNS resources manage routes from different namespaces.

spec:
source:
gatewayRoutes:
enabled: true
annotationFilter: "cfgate.io/dns-sync=enabled"
namespaceSelector:
matchLabels:
environment: production
matchNames:
- app-team-a
- app-team-b

Defines explicit hostnames independently of Gateway API route discovery. Hostnames are case-insensitive and an optional trailing dot is ignored before merging or recording write intents. An explicit entry overrides the discovered settings for the same hostname. Equivalent explicit entries are deduplicated; settings within the same source are compared after default inheritance and proxied-TTL normalization. Equivalent effective records converge; different targets, proxy settings or effective TTLs are rejected before publication. A later default change can make previously equivalent sources conflict.

The target field overrides the resource-level resolved target for that hostname. It supports the {{ .TunnelDomain }} template variable, which resolves to the tunnel’s CNAME target domain when tunnelRef is set. When target is omitted, the resource-level resolved target is used.

spec:
source:
explicit:
- hostname: app.example.com
target: "{{ .TunnelDomain }}"
proxied: true
ttl: 1
- hostname: api.example.com
proxied: false
ttl: 300

Mixed sources remain additive. In the example below, route discovery can still add other hostnames, but the explicit app.example.com entry wins if a route also advertises that hostname:

spec:
source:
gatewayRoutes:
enabled: true
explicit:
- hostname: app.example.com
target: app-origin.example.net
proxied: false
ttl: 300

Fallback values for records that do not have explicit settings. Per-hostname and per-zone settings take precedence.

A TTL of 1 means Auto. DNS-only records accept explicit TTL values from 60 to 86400 seconds. Proxied records always use Auto, currently 300 seconds. cfgate sends the API value 1 whenever the effective record is proxied, after applying defaults and overrides. The configured TTL stays in your resource and takes effect if proxying is disabled. An omitted per-hostname TTL inherits spec.defaults.ttl; an explicit ttl: 1 selects Auto instead. The same rule applies to discovered routes: omit cfgate.io/ttl to inherit, or set it to "1" for Auto. Proxy settings inherit from the most specific configured zone, then the resource defaults, unless the hostname or route overrides them.

spec:
defaults:
proxied: true
ttl: 1

Ownership uses status.ownerId = <installation namespace UID>/<CloudflareDNS UID>, persisted before external writes. The manager obtains its installation namespace from POD_NAMESPACE or --installation-namespace; out-of-cluster development must supply that flag. Renames/recreations cannot reuse an old resource identity. Deleting and recreating the installation namespace changes its identity and requires an explicit migration.

Data comments contain the exact marker cfgate/owner=<owner-id>. With the persisted namespace UID/resource UID identity, this is 86 characters and fits the 100-character DNS comment limit. Existing exact heritage=cfgate,cfgate/owner=<owner-id> data comments remain recognized for owned updates and cleanup. Companion TXT content retains heritage=cfgate,cfgate/owner=<owner-id> and also includes cfgate/resource=CloudflareDNS/<namespace>/<name>; the default name is _cfgate.<hostname>. The default is to create and verify both markers. Disabling TXT creation does not disable data ownership checks or permit existing foreign TXT claims.

spec.ownership.ownerId is a deprecated legacy hint; it no longer overrides resource identity. The deprecated comment configuration is also ignored. A cosmetic managed by cfgate comment is not ownership evidence. Foreign or ambiguous TXT records, foreign data markers, and unmarked existing records block synchronization. cfgate.io/adopt-existing: "true" permits explicitly inspected, unmarked legacy data only; it never overwrites a foreign owner.

Fresh reads detect observable competing claims before writes and deletes. Cloudflare provides no conditional DNS mutation here; a read is not a distributed lock. Coordinate writers across clusters and installations. See authorization and ownership migration before upgrading legacy resources.

spec:
ownership:
txtRecord:
enabled: true
prefix: "_cfgate"

Controls what happens to DNS records when they are no longer needed. All fields are pointer booleans (*bool); nil defaults to true.

FieldDefaultDescription
deleteOnRouteRemovaltrueDelete obsolete records when their hostname, type, or selected zone leaves the desired configuration. Applies to discovered and explicit hostnames.
deleteOnResourceRemovaltrueDelete all managed DNS records when the CloudflareDNS resource itself is deleted (finalizer-driven).
onlyManagedtrueCompatibility field; false does not bypass exact ownership verification.

With policy: sync and route-removal cleanup enabled, cfgate deletes the old record and ownership claim before publishing a replacement in another zone or with another type. This can briefly interrupt DNS availability. Failed cleanup retains the recorded identity in status.records, reports a failure, and retries before further publication. A failed record update also retains the previous ID for cleanup.

Deletion uses the recorded zoneId, even if that zone is no longer configured. Legacy status without a zone ID falls back to the most specific configured zone and still checks the recorded ID and ownership. If no zone matches, cleanup stops with an error; restore the original zone configuration or explicitly choose orphan deletion after reviewing the remote records.

Disabling route-removal cleanup, or using a policy that prevents deletion, retains obsolete record identities for later cleanup. The status inventory is limited to 1,000 entries; cfgate rejects additions that would exceed this limit before creating remote records. Resource deletion still follows deleteOnResourceRemoval and the configured policy.

Use both cleanup settings to remove obsolete records and clean up on resource deletion:

spec:
cleanupPolicy:
deleteOnRouteRemoval: true
deleteOnResourceRemoval: true
onlyManaged: true

Cloudflare API credentials. Required when using externalTarget. When using tunnelRef, credentials are inherited from the referenced CloudflareTunnel and this field can be omitted.

See CloudflareTunnel spec.cloudflare for full credential configuration details.

References a Secret containing fallback Cloudflare API credentials. Used during deletion when the primary credentials (either explicit or inherited from tunnel) are unavailable. This enables cleanup of DNS records even if the credentials Secret has been deleted.

spec:
fallbackCredentialsRef:
name: cloudflare-admin-credentials
namespace: cfgate-system
FieldTypeDescription
status.syncedRecordsint32Number of DNS records successfully synchronized.
status.pendingRecordsint32Number of DNS records awaiting synchronization.
status.ownerIdstringPersisted installation namespace UID/resource UID; used for cleanup.
status.failedRecordsint32Number of DNS records that failed to sync.
status.records[][]DNSRecordSyncStatusRecord inventory, including retained records and failed cleanup obligations. Max 1000 entries.
status.records[].hostnamestringDNS hostname of the record.
status.records[].typestringRecord type (CNAME, A, AAAA).
status.records[].targetstringRecord target/content value.
status.records[].proxiedboolWhether Cloudflare proxy is enabled for this record.
status.records[].ttlint32Record TTL in seconds.
status.records[].statusstringSync status: Synced, Pending, Skipped, or Failed.
status.records[].recordIdstringCloudflare DNS record ID.
status.records[].zoneIdstringCloudflare zone ID where the record was created.
status.records[].errorstringSynchronization or cleanup error when status is Failed.
status.resolvedTargetstringResolved CNAME target (tunnel domain or external target value).
status.observedGenerationint64Last .metadata.generation observed by the controller.
status.lastSyncTimemetav1.TimeLast time DNS records were synced to Cloudflare.
status.conditions[]metav1.ConditionStandard Kubernetes conditions (see below).
ConditionDescription
ReadyDNS sync is operational: credentials valid, zones resolved, and records synced. Target resolution failures are surfaced through this condition with reason TargetResolutionFailed.
CredentialsValidCloudflare API credentials have been validated.
ZonesResolvedAll configured zones have been resolved via the Cloudflare API (or verified by explicit ID).
RecordsSyncedDNS records have been synchronized to Cloudflare.
OwnershipVerifiedTXT ownership records have been verified for all managed DNS records. This condition is diagnostic and does not gate Ready.
ColumnJSONPathDescription
Ready.status.conditions[?(@.type=='Ready')].statusWhether DNS sync is operational (True/False/Unknown).
Synced.status.syncedRecordsNumber of successfully synced records.
Pending.status.pendingRecordsNumber of records awaiting sync.
Failed.status.failedRecordsNumber of records that failed to sync.
Age.metadata.creationTimestampAge of the resource.

Tunnel-backed DNS with Gateway API route discovery

Section titled “Tunnel-backed DNS with Gateway API route discovery”
apiVersion: cfgate.io/v1alpha1
kind: CloudflareDNS
metadata:
name: prod-dns
namespace: cfgate-system
spec:
tunnelRef:
name: prod-tunnel
zones:
- name: example.com
- name: example.org
source:
gatewayRoutes:
enabled: true
annotationFilter: "cfgate.io/dns-sync=enabled"
defaults:
proxied: true
ttl: 1
policy: sync
ownership:
txtRecord:
enabled: true
prefix: "_cfgate"
apiVersion: cfgate.io/v1alpha1
kind: CloudflareDNS
metadata:
name: external-dns
namespace: cfgate-system
spec:
externalTarget:
type: A
value: "203.0.113.10"
cloudflare:
accountId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
secretRef:
name: cloudflare-api-token
zones:
- name: example.com
id: "zone123abc"
source:
explicit:
- hostname: api.example.com
proxied: false
ttl: 300
- hostname: www.example.com
proxied: true
ttl: 1
policy: upsert-only
cleanupPolicy:
deleteOnRouteRemoval: false
deleteOnResourceRemoval: false
onlyManaged: true

Multi-tenant namespace-scoped route discovery

Section titled “Multi-tenant namespace-scoped route discovery”
apiVersion: cfgate.io/v1alpha1
kind: CloudflareDNS
metadata:
name: team-a-dns
namespace: cfgate-system
spec:
tunnelRef:
name: prod-tunnel
zones:
- name: example.com
proxied: true
source:
gatewayRoutes:
enabled: true
annotationFilter: "cfgate.io/dns-sync=enabled"
namespaceSelector:
matchLabels:
team: team-a
matchNames:
- team-a-apps
- team-a-staging
defaults:
proxied: true
ttl: 1
policy: sync
ownership:
txtRecord:
enabled: true
cleanupPolicy:
deleteOnRouteRemoval: true
deleteOnResourceRemoval: true
onlyManaged: true
fallbackCredentialsRef:
name: cloudflare-admin-credentials
namespace: cfgate-system

The controller adds the finalizer cfgate.io/dns-cleanup to every CloudflareDNS resource. When the resource is deleted, the controller attempts to delete all owned DNS records and their TXT ownership records before removing the finalizer.

Deletion also inventories zones from the specification and recorded status to recover remote writes that succeeded before status was saved. Exact-owned data records for unrecorded hostnames are deleted before their companion TXT claims, including when older status records already exist. Data recovery also runs when TXT creation is disabled. Disabling TXT creation does not orphan existing owned claims: both recorded and recovered claims remain subject to normal cleanup. Every recovery-zone inventory must succeed before unrecorded records are deleted; a failed data deletion or a changed record identity retains the claims and finalizer for a fresh attempt. Recorded hostnames keep their status-backed record-ID checks. Foreign, ambiguous, and unmarked records remain protected by the ownership checks.

If cleanup fails, the controller blocks indefinitely and requeues every 15 seconds. It never removes the finalizer automatically. Before deletion has been pending for 1 minute, the controller emits Warning events with reason CleanupFailed. After that warning threshold, failed attempts emit CleanupBlocked. This threshold does not bound API calls or stop retries. Cleanup can finish on a later retry or after credentials, permissions, or connectivity are repaired.

To skip Cloudflare cleanup and remove the finalizer immediately, set the cfgate.io/deletion-policy=orphan annotation on the CloudflareDNS resource. The controller will leave DNS records in Cloudflare and remove the finalizer without attempting cleanup.

Terminal window
kubectl annotate cloudflarednses my-dns -n cfgate-system \
cfgate.io/deletion-policy=orphan

When using tunnelRef, all DNS records are CNAME records pointing to {tunnelId}.cfargotunnel.com. The record type is not configurable in tunnel-ref mode.

When using externalTarget, the type field determines the DNS record type. Valid values are CNAME, A, and AAAA. The value field contains the record target: a domain name for CNAME, an IPv4 address for A, or an IPv6 address for AAAA. The controller does not validate that A or AAAA target values are valid IP addresses; values are passed through to the Cloudflare API.

Only A, AAAA, and CNAME records can be proxied by Cloudflare. Setting proxied: true on other record types will cause the Cloudflare API to reject the request.

Cloudflare Universal SSL certificates cover *.example.com but not deeper wildcards such as *.sub.example.com. Hostnames with more than one subdomain level relative to the zone (for example, api.staging.example.com in zone example.com) require Cloudflare Advanced Certificate Manager or a custom certificate uploaded to Cloudflare.

The controller emits a DeepSubdomain warning event when it encounters a hostname with depth greater than 1 relative to the zone. This warning is informational only; record creation proceeds regardless.

To suppress the warning, set the annotation cfgate.io/allow-deep-subdomains: "true" on the CloudflareDNS resource.

Terminal window
kubectl annotate cloudflarednses my-dns -n cfgate-system \
cfgate.io/allow-deep-subdomains=true

When spec.source.gatewayRoutes.namespaceSelector is set, only routes from matching namespaces are considered for DNS record creation. The selector supports two filters: matchLabels and matchNames.

matchLabels uses AND semantics: all specified labels must be present on the namespace and have the required value. An empty required value matches an explicitly empty label, not a missing label. matchNames matches namespaces by name. If both filters are specified, the result is a union (a namespace matching either filter is included).

An empty selector (namespaceSelector: {}) matches all namespaces, following the Kubernetes convention used by NetworkPolicy and other resources.

Before writing DNS, cfgate records the resolved destination and a write identifier in status.pendingWrites. New data records carry that identifier alongside their owner marker. If Cloudflare accepts a write but Kubernetes cannot save its result, the next reconciliation recovers the record from this intent. Later zone or hostname edits do not erase the pending destination.

A different record ID is recovered only when the saved intent and ownership evidence identify it. An unexplained replacement or foreign ownership claim blocks cleanup and keeps the finalizer. Inspect the reported conflict before deciding whether to restore the owned record or use the documented orphan deletion policy. Do not remove pending status to bypass recovery.

spec.ownership.txtRecord.prefix is immutable. To change it, delete the DNS resource, wait for its configured cleanup to finish, then recreate it with the new prefix. Disabling txtRecord.enabled stops creating companion claims; existing claims remain protected and are removed during normal hostname or resource cleanup. Re-enabling it uses the same prefix. The controller also retains the established prefix in status, so a schema mismatch cannot silently redirect cleanup.

For resources created before this change, the first reconciliation records their current prefix. cfgate cannot reconstruct prefixes changed before that checkpoint. Inspect any older ownership claims during such a migration. Install the matching CRD before updating the controller so Kubernetes preserves the recovery fields.

The 1,000-entry inventory ceiling bounds stored cleanup state. It is not a tested operating capacity. Cloudflare’s standard limit is 1,200 API requests per five minutes, shared with other calls using the applicable identity.

The controller’s synthetic CNAME tests count about 12 provider operations per new hostname, 6 per unchanged hostname, and 8 per hostname during cleanup, plus zone inventory reads. Each hostname includes its TXT claim. These counts exclude SDK retries, additional pagination, zone resolution, and other controllers. Consequently, 1,000 unchanged hostnames already exceed the standard window. Smaller resources still share the same quota; splitting them does not increase it.

Quota tests cover two resources with 10, 50, or 100 hostnames each, interrupted publication, and cleanup across renewed shared windows. They establish recovery in that model, not a production capacity recommendation. Measure request volume and reconciliation latency for your account before increasing scale. Large inventories need additional batching and shared-budget scheduling; retries alone do not guarantee progress when preliminary reads consume the entire window. Fresh ownership checks remain required before writes.