Repair Core Records
CNAME to A record type change fails on existing RRSet
You changed a hostname from a CNAME to a plain A record, your deploy tool said it worked, and the DNS host still rejects it or still serves the old alias. This happens because DNS will never let a CNAME and an A record share the same name, even for a single moment. Here is why the swap fails and a script that fixes it without the race.
DNS forbids a CNAME from sharing a name with any other record type. When a script deletes the old CNAME and creates the new A record as two separate calls, the create can race ahead of the delete and get rejected because the CNAME is still there. Fix it by turning the change into one atomic update on the existing record's ID, so the CNAME and the A record are never both present at once.
The problem in plain words
A hostname can only mean one thing in DNS. Either it is an alias that points somewhere else with a CNAME, or it is a real address with an A record. It cannot be both, not even for a second. This rule comes straight from RFC 1035 and is repeated as a common error to avoid in RFC 1912.
Most deploy tools change a record type by removing the old one and adding the new one as two separate API calls. If those two calls are not strictly ordered, or if the second one is sent before the first one is confirmed, the DNS host still sees the old CNAME sitting there when the new A record arrives. It rejects the new record with a same-name conflict, and your change fails halfway through.
Why it happens
- DNS forbids a CNAME from coexisting with any other record type at the same name, per RFC 1035 section 3.6.2 and RFC 1912 section 2.4.
- Reconciliation tools such as Terraform and external-dns typically model a type change as a plan with two separate actions, a delete and a create, rather than one update.
- If the create request races ahead of the delete finishing, or the two calls are sent out of order, the DNS host still holds the old CNAME when it receives the new A record.
- The API then rejects the create with a same-name conflict, because the zone still has a CNAME at that exact hostname.
- The Terraform Cloudflare provider has an open issue describing this exact case, where the new resource is created before the old one is destroyed and Cloudflare returns "A CNAME already exists with that host."
The failure is not about the record values being wrong. It is about timing. Two independent calls, a delete and a create, leave a window where the old record and the new record could both exist, and DNS refuses to allow that window. Collapsing the change into one call on the same record ID removes the window entirely.
The fix, as a flow
Instead of asking "did the delete finish yet," change the question to "what is live right now, and does it match what I want." A script reads the current record at the name, compares it to the desired record, and if the only thing at that name is a CNAME where an A record belongs, it overwrites that same record in place with one call. No delete, no create, no race.
How to fix it
Confirm what is actually live
Before touching anything, check both record types at the name. If the CNAME still returns a target when you expected an A record with an IP, the old record was never removed.
dig +short CNAME app.example.com
dig +short A app.example.com
Ask the authoritative nameserver directly
Your resolver may still be caching the old answer. Query the zone's own nameserver to see the true current state, with no caching in the way.
dig @ns1.example.com app.example.com CNAME +noall +answer
dig @ns1.example.com app.example.com A +noall +answer
Reproduce the API rejection
Sending the create while the CNAME is still present returns a 400 with a code that names the conflict directly. Cloudflare uses 81057 or 81053 for "Record already exists," and 81054 for "A CNAME already exists with that host" on the reverse swap.
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records" \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"app.example.com","content":"203.0.113.10","ttl":300}'
Do not delete then create. Overwrite in place
The safe fix is one PUT call to the existing record's own ID. It reuses the record instead of removing one and adding another, so there is never a moment where both a CNAME and an A record exist at the name.
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/dns_records/<existing_record_id>" \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
--data '{"type":"A","name":"app.example.com","content":"203.0.113.10","ttl":300}'
If your tool truly must delete and create as two calls, the fallback is to sequence them strictly: call DELETE /zones/{zone_id}/dns_records/{cname_record_id} and wait for a 200, then only after that succeeds call POST /zones/{zone_id}/dns_records with the new A record. The single PUT above is preferred because it removes the race instead of just managing it.
How to check it worked
Run the same checks from step 1 again. The CNAME should be gone, and only the A record should answer.
dig +short CNAME app.example.com
# expect: empty output, nothing returned
dig +short A app.example.com
# expect: 203.0.113.10
dig @ns1.example.com app.example.com A +noall +answer
# expect: one A record, no CNAME alongside it
A good result is exactly one record type at that name, every resolver agreeing on the IP, and the same POST or PUT call from step 3 now returning HTTP 200 with "success":true and a type field of "A".
The full code
Here is a complete script for each language. It reads the live records at a name from Cloudflare, decides whether the change is a noop, a plain create, or an overwrite of a conflicting record, and only writes when told to. The decision is a pure function with no network calls, so it is easy to test on its own.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a CNAME left behind at a name where an A record is wanted, and
repair it with one atomic Cloudflare PUT instead of a delete-then-create race.
Safe to run again and again.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_cname_to_a")
def plan_rrset_change(live_records, desired):
"""Pure decision. No I/O. live_records: list of dicts with id, type,
content for the name. desired: dict with name, type, content, ttl.
Returns one of:
{"action": "noop"}
{"action": "overwrite", "record_id": }
{"action": "create"}
"""
for rec in live_records:
if rec["type"] == desired["type"] and rec["content"] == desired["content"]:
return {"action": "noop"}
conflicting = [r for r in live_records if r["type"] != desired["type"]]
if len(conflicting) == 1:
return {"action": "overwrite", "record_id": conflicting[0]["id"]}
if not live_records:
return {"action": "create"}
return {"action": "noop"}
def run():
# Imported lazily so this module can be unit tested with no network
# and no DNS libraries installed.
import dns.resolver
import requests
domain = os.environ["DNS_DOMAIN"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
desired_ip = os.environ["DESIRED_A_RECORD_IP"]
ttl = int(os.environ.get("DESIRED_TTL", "300"))
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
base = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"
try:
answers = dns.resolver.resolve(domain, "CNAME")
log.info("Resolver still sees a CNAME for %s: %s", domain, answers[0].target)
except Exception:
log.info("Resolver shows no CNAME for %s", domain)
resp = requests.get(base, headers=headers, params={"name": domain}, timeout=30)
resp.raise_for_status()
live_records = [
{"id": r["id"], "type": r["type"], "content": r["content"]}
for r in resp.json()["result"]
]
desired = {"name": domain, "type": "A", "content": desired_ip, "ttl": ttl}
plan = plan_rrset_change(live_records, desired)
log.info("Plan for %s: %s", domain, plan)
if plan["action"] == "noop":
log.info("Nothing to do. %s already matches.", domain)
return
if dry_run:
log.info("Dry run is on. Would apply: %s", plan)
return
if plan["action"] == "overwrite":
record_id = plan["record_id"]
r = requests.put(
f"{base}/{record_id}",
headers=headers,
json={"type": "A", "name": domain, "content": desired_ip, "ttl": ttl},
timeout=30,
)
r.raise_for_status()
log.info("Overwrote record %s in place. Now type A, content %s.", record_id, desired_ip)
elif plan["action"] == "create":
r = requests.post(
base,
headers=headers,
json={"type": "A", "name": domain, "content": desired_ip, "ttl": ttl},
timeout=30,
)
r.raise_for_status()
log.info("Created new A record for %s pointing to %s.", domain, desired_ip)
if __name__ == "__main__":
run()
/**
* Detect a CNAME left behind at a name where an A record is wanted, and
* repair it with one atomic Cloudflare PUT instead of a delete-then-create
* race. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
export function planRrsetChange(liveRecords, desired) {
for (const rec of liveRecords) {
if (rec.type === desired.type && rec.content === desired.content) {
return { action: "noop" };
}
}
const conflicting = liveRecords.filter((r) => r.type !== desired.type);
if (conflicting.length === 1) {
return { action: "overwrite", recordId: conflicting[0].id };
}
if (liveRecords.length === 0) {
return { action: "create" };
}
return { action: "noop" };
}
export async function run() {
// Imported lazily so this module can be unit tested with no network
// and no DNS libraries installed.
const dns = await import("node:dns/promises");
const domain = process.env.DNS_DOMAIN;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const desiredIp = process.env.DESIRED_A_RECORD_IP;
const ttl = Number(process.env.DESIRED_TTL || 300);
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const base = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
try {
const answers = await dns.resolveCname(domain);
console.log(`Resolver still sees a CNAME for ${domain}: ${answers[0]}`);
} catch {
console.log(`Resolver shows no CNAME for ${domain}`);
}
const listRes = await fetch(`${base}?name=${encodeURIComponent(domain)}`, { headers });
if (!listRes.ok) throw new Error(`Cloudflare list returned ${listRes.status}`);
const listBody = await listRes.json();
const liveRecords = listBody.result.map((r) => ({ id: r.id, type: r.type, content: r.content }));
const desired = { name: domain, type: "A", content: desiredIp, ttl };
const plan = planRrsetChange(liveRecords, desired);
console.log(`Plan for ${domain}:`, plan);
if (plan.action === "noop") {
console.log(`Nothing to do. ${domain} already matches.`);
return;
}
if (dryRun) {
console.log(`Dry run is on. Would apply:`, plan);
return;
}
if (plan.action === "overwrite") {
const putRes = await fetch(`${base}/${plan.recordId}`, {
method: "PUT",
headers,
body: JSON.stringify({ type: "A", name: domain, content: desiredIp, ttl }),
});
if (!putRes.ok) throw new Error(`Cloudflare update returned ${putRes.status}`);
console.log(`Overwrote record ${plan.recordId} in place. Now type A, content ${desiredIp}.`);
} else if (plan.action === "create") {
const postRes = await fetch(base, {
method: "POST",
headers,
body: JSON.stringify({ type: "A", name: domain, content: desiredIp, ttl }),
});
if (!postRes.ok) throw new Error(`Cloudflare create returned ${postRes.status}`);
console.log(`Created new A record for ${domain} pointing to ${desiredIp}.`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The decision function is the part worth testing on its own, since it decides whether a live DNS record gets overwritten. It takes plain lists and dicts, makes no network call, and is fast to run.
from fix_cname_to_a import plan_rrset_change
def desired(**over):
base = {"name": "app.example.com", "type": "A", "content": "203.0.113.10", "ttl": 300}
base.update(over)
return base
def test_noop_when_already_matching():
live = [{"id": "rec_1", "type": "A", "content": "203.0.113.10"}]
assert plan_rrset_change(live, desired()) == {"action": "noop"}
def test_overwrite_when_one_conflicting_cname():
live = [{"id": "rec_9", "type": "CNAME", "content": "old-target.example.net"}]
assert plan_rrset_change(live, desired()) == {"action": "overwrite", "record_id": "rec_9"}
def test_create_when_nothing_exists():
assert plan_rrset_change([], desired()) == {"action": "create"}
def test_noop_when_multiple_conflicting_records():
live = [
{"id": "rec_1", "type": "TXT", "content": "v=spf1 -all"},
{"id": "rec_2", "type": "MX", "content": "mail.example.com"},
]
assert plan_rrset_change(live, desired())["action"] == "noop"
import { test } from "node:test";
import assert from "node:assert/strict";
import { planRrsetChange } from "./fix-cname-to-a.js";
const desired = (over = {}) => ({
name: "app.example.com",
type: "A",
content: "203.0.113.10",
ttl: 300,
...over,
});
test("noop when already matching", () => {
const live = [{ id: "rec_1", type: "A", content: "203.0.113.10" }];
assert.deepEqual(planRrsetChange(live, desired()), { action: "noop" });
});
test("overwrite when one conflicting cname", () => {
const live = [{ id: "rec_9", type: "CNAME", content: "old-target.example.net" }];
assert.deepEqual(planRrsetChange(live, desired()), { action: "overwrite", recordId: "rec_9" });
});
test("create when nothing exists", () => {
assert.deepEqual(planRrsetChange([], desired()), { action: "create" });
});
test("noop when multiple conflicting records", () => {
const live = [
{ id: "rec_1", type: "TXT", content: "v=spf1 -all" },
{ id: "rec_2", type: "MX", content: "mail.example.com" },
];
assert.equal(planRrsetChange(live, desired()).action, "noop");
});
Case studies
The apply that half finished every time
A team moved a staging subdomain off a CDN CNAME to a direct A record. Terraform's plan showed one resource destroyed and one created. On every apply, the create failed with Cloudflare's "A CNAME already exists with that host" error, because the provider sent the create before the destroy had fully propagated on Cloudflare's side.
They stopped modeling the change as destroy plus create and instead pointed both the old and new resource blocks at the same record ID so Terraform issued a single update. The next apply succeeded on the first try.
The Kubernetes ingress that kept flapping
A cluster's external-dns controller was reconciling an ingress from a CNAME to an A record on every sync loop, since the change never fully committed and the controller saw a diff again next cycle. Each attempt logged the same 81057 conflict.
Switching the annotation so the controller updated the existing record instead of tearing it down first ended the loop. The dig checks afterward showed one clean A record with no leftover CNAME.
After the fix, there is exactly one record at the name, the deploy tool's own conflict-detection call returns success on the same request that used to fail, and dig from both a normal resolver and the authoritative nameserver agree on the answer. No delete-then-create race left to fail on the next deploy.
FAQ
Why does changing a CNAME to an A record fail with a conflict?
DNS will not let a CNAME and any other record type sit at the same name at once. If your tool sends the create for the new A record before the delete of the old CNAME has finished, the DNS host still sees the CNAME and rejects the new record as a same-name conflict.
How do I know if this is what is happening to me?
Run dig +short CNAME on the name. If it still returns a target after your tool reported success, the old record was never fully removed before the new one was attempted. The API error usually names the existing record type directly, such as Cloudflare's 81057 or 81054 codes.
What is the actual fix?
Stop sending a delete and a create as two separate calls. Either wait for the delete to return success before sending the create, or better, send one PUT to the existing record's ID that overwrites its type and value. That way both records never exist at the same name at the same time.
Related field notes
Citations
On the problem:
- RFC 1035 section 3.6.2, the rule that a CNAME cannot coexist with other record types at the same name. ietf.org/rfc/rfc1035.txt
- RFC 1912, Common DNS Operational and Configuration Errors, section 2.4. ietf.org/rfc/rfc1912.txt
- Cloudflare docs on the "Cannot add DNS records with the same name" error. developers.cloudflare.com/dns
On the solution:
- cloudflare/terraform-provider-cloudflare issue #750, the create-before-destroy race producing error 81054. github.com/cloudflare/terraform-provider-cloudflare/issues/750
- Cloudflare API reference for updating (overwriting) a DNS record with PUT. developers.cloudflare.com/api
- Cloudflare API reference for editing a DNS record with PATCH. developers.cloudflare.com/api
Stuck on a tricky one?
If you have a DNS cutover, a Terraform reconciliation race, or a zone that will not converge the way you expect, this is the kind of work I do. Message me and we can work through it together.
Did this fix your DNS conflict?
If this saved you a broken deploy or a confusing on-call page, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi