Cloudflare dns zones
Skill Goodsmileduck/claude-registry/plugins/cloud-platform-skills/skills/cloudflare-dns-zones
Community marketplace of Claude Code plugins: DevOps skill packs (Terraform, Kubernetes, CI/CD, cloud platforms, DigitalOcean), CLAUDE.md optimization, and diagramming — gated by a best-practices lint + security-audit CI.
npx -y skills add Goodsmileduck/claude-registry --skill cloudflare-dns-zonesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Operates Cloudflare DNS zones and records via the REST API (curl + jq) — token scoping, zone discovery, record CRUD, batch operations, BIND import/export, proxied vs DNS-only decisions, CNAME flattening at apex, DNSSEC, and DNS-01 ACME challenge wiring with cert-manager. Use when working with Cloudflare DNS, `api.cloudflare.com`, `CLOUDFLARE_API_TOKEN` (or legacy `CF_API_TOKEN`), zone records, DNS-01 challenges, mail records (MX/SPF/DKIM/DMARC), or "orange cloud / grey cloud" proxy decisions.
SKILL.md
16.4 KB, as published. Nobody here has run it
Cloudflare DNS Zones
Operational skill for managing Cloudflare DNS through the REST API. Not for Terraform — see the Cloudflare provider docs if IaC is wanted. This skill focuses on the API directly (curl + jq), which is the source of truth every wrapper is built on.
When to invoke
Symptoms:
- Need to add, update, or audit DNS records via script/CI rather than the dashboard.
- A record was switched to proxied (orange cloud) and a non-HTTP service stopped working.
- DNS-01 ACME challenges fail despite a token that "should have permission."
- Bulk record migration into or out of Cloudflare (BIND zone file in hand).
- DKIM, SPF, DMARC TXT records being authored and the long-string semantics matter.
- DNSSEC handoff to the parent registrar.
- The token in use is the deprecated Global API Key.
Cross-cutting rules
- Never use the Global API Key. It's account-wide and can't be scoped. Use API Tokens (Profile → API Tokens). Every example below uses
Authorization: Bearer $CLOUDFLARE_API_TOKEN— the same variable wrangler, thecfCLI, and the official SDK read, so one export serves every Cloudflare tool. - Scope tokens to the minimum. A token for DNS work needs
Zone:Read+Zone:DNS:Editon the specific zones it operates. Not "All Zones" unless the workload genuinely touches all zones. - Discover zone IDs at runtime. Hard-coding zone IDs in scripts is brittle — they change when zones are recreated. Look them up by name on each run.
- Idempotent operations require list-then-act. There is no "upsert by name+type" endpoint. Always
GETfiltered bynameandtypefirst, thenPOST(create) orPATCH(update by ID). - Verify proxiability before setting
proxied: true. Only HTTP-serving hostnames should be proxied. See proxied vs DNS-only.
API basics
Base URL: https://api.cloudflare.com/client/v4
Auth header: Authorization: Bearer $CLOUDFLARE_API_TOKEN
Response envelope: every endpoint returns:
{
"success": true|false,
"errors": [...],
"messages": [...],
"result": {...} | [...]
}
Always check .success and extract from .result. Don't assume the top-level is the data.
Useful curl flags: -sf (silent + fail on HTTP 4xx/5xx, so the shell sees a non-zero exit). For body-on-error, replace with -s and check .success in jq.
# Verify token works and see what it can do
curl -sf -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
https://api.cloudflare.com/client/v4/user/tokens/verify | jq .result.status
# Expected: "active"
Token scoping
API tokens are created at the user level (Profile → API Tokens → Create Token). Three knobs:
| Knob | Effect |
|---|---|
| Permissions | Per-product, per-action. For DNS work: Zone:Read + Zone:DNS:Edit. (Dashboard navigates as Zone → DNS → Edit.) |
| Resources | Which accounts and zones. Prefer "Include → Specific zone" over "All zones from an account." |
| Client IP / TTL | Optional. CI tokens may not have a stable IP; leave Client IP unrestricted unless you control the runner IP. |
Minimum-scope cookbook:
- Read-only DNS audit:
Zone:Readon the target zones. Nothing else. - Record CRUD:
Zone:Read+Zone:DNS:Editon target zones. - DNS-01 ACME challenges (cert-manager):
Zone:Read(all zones — needed for zone discovery by name) +Zone:DNS:Editon the specific zones holding_acme-challengerecords. See DNS-01 with cert-manager. - Zone provisioning (rare):
Account:Cloudflare DNS:Editat account level.
Zone discovery
# By exact name
ZONE_ID=$(curl -sf -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" \
| jq -r '.result[0].id')
# All zones (paginated; default 20 per page, max 50)
curl -sf -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?per_page=50&page=1" \
| jq -r '.result[] | "\(.id)\t\(.name)\t\(.status)"'
Trap: ?name= is exact match. To search by suffix you must list and filter client-side. The name= filter on /zones does NOT support wildcards.
Record CRUD
All record operations are under /zones/{zone_id}/dns_records. The full method set:
| Method | Endpoint | Use |
|---|---|---|
GET | /zones/{zid}/dns_records | List (filter via ?name=, ?type=, ?content=) |
GET | /zones/{zid}/dns_records/{rid} | Read one |
POST | /zones/{zid}/dns_records | Create |
PATCH | /zones/{zid}/dns_records/{rid} | Partial update |
PUT | /zones/{zid}/dns_records/{rid} | Full replace (must send all fields) |
DELETE | /zones/{zid}/dns_records/{rid} | Delete |
POST | /zones/{zid}/dns_records/batch | Atomic mixed-op batch (posts/patches/puts/deletes arrays) |
GET | /zones/{zid}/dns_records/export | Export full zone as BIND file |
POST | /zones/{zid}/dns_records/import | Import BIND file (multipart) |
Create a record
curl -sf -X POST \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-d '{
"type": "A",
"name": "app.example.com",
"content": "198.51.100.4",
"ttl": 1,
"proxied": true,
"comment": "Created by deploy script"
}'
Field notes:
name— the FQDN, not just the subdomain.app.example.com, notapp.content— for A: IPv4; AAAA: IPv6; CNAME: target FQDN (no trailing dot); TXT: the raw string (no surrounding quotes; the API handles wrapping); MX: requires alsopriority.ttl— seconds (60–86400; Enterprise: 30 minimum).1means "auto" (300s when DNS-only; ignored whenproxied).proxied—trueonly for proxiable types (A, AAAA, CNAME) on HTTP-serving hostnames. Cloudflare ignoresproxied: trueon record types that can't be proxied (MX, TXT, SRV, NS, CAA, PTR).
Idempotent upsert pattern
There is no "upsert by name+type." Implement it as list → decide → write:
NAME="app.example.com"
TYPE="A"
CONTENT="198.51.100.4"
EXISTING_ID=$(curl -sf -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=$TYPE&name=$NAME" \
| jq -r '.result[0].id // empty')
if [ -n "$EXISTING_ID" ]; then
curl -sf -X PATCH \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$EXISTING_ID" \
-d "{\"content\":\"$CONTENT\"}"
else
curl -sf -X POST \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-d "{\"type\":\"$TYPE\",\"name\":\"$NAME\",\"content\":\"$CONTENT\",\"ttl\":1}"
fi
For >1 record, do NOT wrap this snippet in a for loop. List once, then build a single batch payload — see Batch operations. Per-record loops waste 2N requests and hit the rate limit fast.
Batch operations
For >5 mixed changes, use the batch endpoint — single atomic call, all-or-nothing:
curl -sf -X POST \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/batch" \
-d '{
"posts": [
{"type":"TXT","name":"s1._domainkey.example.com","content":"v=DKIM1; k=rsa; p=..."}
],
"patches": [
{"id":"<existing_record_id>","content":"v=DKIM1; k=rsa; p=NEW..."}
],
"deletes": [
{"id":"<old_record_id>"}
]
}'
Notes:
- Atomic: if any single op fails validation, the whole batch is rejected and no changes apply.
- Order:
deletes→patches→puts→posts. Plan accordingly when replacing a record with a different type. - Limit: 1000 ops per batch.
Rate limit
1200 requests per 5 minutes per token (default). Symptoms of hitting it: HTTP 429 with Retry-After. Mitigations:
- Use the batch endpoint instead of per-record loops.
- Add
sleep 0.5between calls in shell loops, or usexargs -P 4for controlled parallelism — not unlimited. - Honor
Retry-Afteron 429.
Proxied vs DNS-only (the orange cloud trap)
Cloudflare's "proxy" sits in front of A/AAAA/CNAME records as an HTTP/HTTPS reverse proxy. It does not pass through other protocols. A proxied hostname that handles non-HTTP traffic is silently broken at the edge.
| Use | Proxy setting |
|---|---|
| Web server (HTTP/HTTPS) | proxied: true — gets TLS, WAF, caching, DDoS protection |
| Mail server (SMTP/IMAP/POP3) | proxied: false — proxy drops these protocols at the edge |
| SSH/Git over SSH | proxied: false — same reason |
| Custom TCP/UDP service on non-HTTP port | proxied: false (Spectrum is the paid exception) |
| MX record target | proxied: false (always — MX targets must accept SMTP) |
| FTP, gaming, IoT | proxied: false |
When in doubt: if the service speaks HTTP(S), proxy it. Otherwise don't.
Cloudflare does not warn you when proxying breaks a service. Set MX target hostnames and any non-HTTP A records to DNS-only and document why with the comment field.
CNAME flattening at apex
Cloudflare allows CNAME records at the zone apex (e.g., example.com CNAME app.cdn.com) — DNS standards disallow this, but Cloudflare "flattens" by resolving the CNAME at query time and returning A/AAAA records to the client.
Implications:
example.comwith a CNAME to a third-party CDN works without special config.- The
proxiedflag still applies to the CNAME; flattening is transparent. - The flattened target must itself resolve to A/AAAA; if the target is also a CNAME chain, flattening follows the chain.
- Some legacy resolvers may behave oddly; in practice this is rare.
When NOT to rely on apex CNAME flattening: when you have other infrastructure (Argo, Magic DNS, etc.) that expects real A records at apex. Use A/AAAA records to the same IPs the CNAME would flatten to.
Mail records: SPF, DKIM, DMARC, MX
All four are TXT records (SPF is not a separate SPF type — the RFC deprecated that). Format reminders:
# SPF — only ONE SPF TXT per hostname. If you have multiple senders, merge into one string.
# Record: example.com TXT
"v=spf1 include:_spf.google.com include:mailgun.org ~all"
# DKIM — one per selector. Long key values are auto-split by Cloudflare.
# Record: selector1._domainkey.example.com TXT
"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3..."
# DMARC — at _dmarc.<domain>
# Record: _dmarc.example.com TXT
"v=DMARC1; p=reject; rua=mailto:[email protected]; ruf=mailto:[email protected]; pct=100"
# MX — separate record type, requires priority
# Record: example.com MX (priority 10) → mail.example.com
Traps:
- Multiple SPF records on the same hostname — invalid per RFC 7208. Mail providers may treat the domain as unauthenticated. Combine into one.
- Trailing dot in MX/CNAME content — Cloudflare accepts both
mail.example.comandmail.example.com.; both work, but be consistent in audits. - Proxied MX target — see proxied vs DNS-only.
Bulk import/export (BIND)
For zone migrations:
# Export: returns BIND zone file as plain text
curl -sf -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/export" \
-o example.com.zone
# Import: multipart upload
curl -sf -X POST -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-F "[email protected]" \
-F "proxied=false" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/import"
What does NOT round-trip:
- The
proxiedflag — import sets all proxiable records according to theproxiedform field. Adjust per-record after import if needed. - Custom TTLs on records that get auto-proxied — proxied records ignore TTL.
- Comments and tags — these are Cloudflare extensions, not standard BIND.
Use export for backup before risky changes; use import only for fresh-zone migrations.
DNSSEC
# Enable — response body contains the DS record fields
curl -sf -X PATCH \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dnssec" \
-d '{"status":"active"}' \
| jq '.result | {ds, key_tag, algorithm, digest_type, digest, status}'
To re-fetch later: GET /zones/$ZONE_ID/dnssec returns the same shape.
The handoff:
- PATCH to
active— Cloudflare publishes DNSKEY records in the zone. - Read
.result.ds(full DS record string),.key_tag,.algorithm,.digest_type,.digest. - Enter the DS record at the registrar (where the domain is registered, often a different vendor than Cloudflare).
- After registrar publishes, status changes from
pending→active. Can take hours.
Trap: enabling DNSSEC in Cloudflare without entering the DS record at the registrar does nothing useful. The chain of trust is broken; clients fall back as if DNSSEC wasn't there.
DNS-01 with cert-manager
cert-manager Issuer with Cloudflare DNS-01:
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- example.com
Token scope: Zone:Read (across all zones — needed for zone discovery by name) + Zone:DNS:Edit on the specific zones holding _acme-challenge records.
The delegated-subdomain trap:
If you request a cert for *.prod.example.com and prod.example.com is a separate Cloudflare zone (not a record in the example.com zone), the token must have DNS:Edit on the prod.example.com zone — not just example.com. Symptom: presenting challenge: NS lookup found no answer for _acme-challenge.prod.example.com.
Diagnostic:
# Which zone is authoritative for the challenge name?
dig +trace _acme-challenge.prod.example.com NS @1.1.1.1
# Did the record actually land somewhere?
dig +trace _acme-challenge.prod.example.com TXT @1.1.1.1
The CNAME delegation pattern — useful when you can't broaden the token, or want all _acme-challenge records consolidated in one zone:
# In example.com zone (token has access):
_acme-challenge.prod.example.com CNAME prod.delegated.example.com
# cert-manager writes to:
_acme-challenge.prod.delegated.example.com TXT "<challenge>"
cert-manager follows the CNAME automatically when cnameStrategy: Follow is set on the solver. The token then only needs DNS:Edit on the delegated.example.com subzone.
Anti-patterns checklist
- ❌ Using the Global API Key. Always use scoped tokens.
- ❌ Hard-coding zone IDs in scripts. Look up by name at runtime.
- ❌ "All Zones" scope on a token that touches one zone.
- ❌
proxied: trueon mail, SSH, or any non-HTTP hostname. - ❌ Multiple SPF TXT records on the same hostname. Merge.
- ❌ Setting up DNSSEC in Cloudflare and forgetting the registrar DS handoff.
- ❌ Per-record loops for bulk operations. Use the
/dns_records/batchendpoint. - ❌ Editing exported BIND files and re-importing as a "diff" mechanism. Use the API endpoints for surgical changes.
- ❌ Trusting
?name=zone lookups to support wildcards. Exact match only.
Cross-skill notes
- For object storage on R2 (Cloudflare's S3-compatible service), see the
cloud-storage-identificationskill — endpoint detection and credential model differ from generic AWS S3 patterns. - For deploy-time DNS automation in GitHub Actions, see the
github-actions-pipelinesskill for OIDC-to-cloud and the secret-management section. - For Workers, Pages, KV, D1, WAF: not yet covered. Build separate skills when friction emerges.