Claude
Skills
Sign in
Back

cloudflare-dns-zones

Included with Lifetime
$97 forever

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`, `CF_API_TOKEN`, zone records, DNS-01 challenges, mail records (MX/SPF/DKIM/DMARC), or "orange cloud / grey cloud" proxy decisions.

Backend & APIs

What this skill does


# 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

1. **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 $CF_API_TOKEN`.
2. **Scope tokens to the minimum.** A token for DNS work needs `Zone:Read` + `Zone:DNS:Edit` on the specific zones it operates. Not "All Zones" unless the workload genuinely touches all zones.
3. **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.
4. **Idempotent operations require list-then-act.** There is no "upsert by name+type" endpoint. Always `GET` filtered by `name` and `type` first, then `POST` (create) or `PATCH` (update by ID).
5. **Verify proxiability before setting `proxied: true`.** Only HTTP-serving hostnames should be proxied. See [proxied vs DNS-only](#proxied-vs-dns-only-the-orange-cloud-trap).

## API basics

**Base URL:** `https://api.cloudflare.com/client/v4`

**Auth header:** `Authorization: Bearer $CF_API_TOKEN`

**Response envelope:** every endpoint returns:

```json
{
  "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.

```bash
# Verify token works and see what it can do
curl -sf -H "Authorization: Bearer $CF_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:Read` on the target zones. Nothing else.
- Record CRUD: `Zone:Read` + `Zone:DNS:Edit` on target zones.
- DNS-01 ACME challenges (cert-manager): `Zone:Read` (all zones — needed for zone discovery by name) + `Zone:DNS:Edit` on the specific zones holding `_acme-challenge` records. See [DNS-01 with cert-manager](#dns-01-with-cert-manager).
- Zone provisioning (rare): `Account:Cloudflare DNS:Edit` at account level.

## Zone discovery

```bash
# By exact name
ZONE_ID=$(curl -sf -H "Authorization: Bearer $CF_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 $CF_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

```bash
curl -sf -X POST \
  -H "Authorization: Bearer $CF_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`, not `app`.
- `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 also `priority`.
- `ttl` — seconds (60–86400; Enterprise: 30 minimum). `1` means "auto" (300s when DNS-only; ignored when `proxied`).
- `proxied` — `true` only for proxiable types (A, AAAA, CNAME) on HTTP-serving hostnames. Cloudflare ignores `proxied: true` on 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:

```bash
NAME="app.example.com"
TYPE="A"
CONTENT="198.51.100.4"

EXISTING_ID=$(curl -sf -H "Authorization: Bearer $CF_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 $CF_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 $CF_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](#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:

```bash
curl -sf -X POST \
  -H "Authorization: Bearer $CF_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.5` between calls in shell loops, or us

Related in Backend & APIs