Repair Core Records

CNAME at zone apex conflicts with NS or SOA

Someone pointed the bare domain, the one without "www", straight at a CNAME target. It looked fine in the dashboard. Then mail stopped arriving, or the domain would not resolve at all, or the DNS provider rejected the record outright. A CNAME on the zone apex breaks a rule that has been in DNS since 1987, and the fix is simple once you know what to look for.

Core DNS records Python and Node.js Safe by default (dry run)
A bunch of blue wires connected to each other
Photo by Scott Rodgerson on Unsplash
The short answer

A CNAME record cannot share a name with any other record, and the zone apex must always carry SOA and NS records. Putting a plain CNAME on the apex either gets rejected by the DNS server or silently breaks the zone, hiding your MX, TXT, NS, and SOA answers. Remove the literal CNAME and replace it with A/AAAA records pointing at the target's IP addresses, or use your provider's CNAME flattening or ALIAS feature, which publishes real A/AAAA answers instead of a CNAME on the wire.

The problem in plain words

A CNAME record is DNS's way of saying "this name is just another name for that name, go look there instead." It is meant for one job: hand off a lookup to a different hostname. The catch, written into the DNS spec from the start, is that when a name has a CNAME, that name cannot have anything else. No A record, no MX, no TXT, nothing.

The zone apex is the bare domain itself, like example.com with nothing in front of it. Every zone is required to have an SOA record and at least one NS record right at that apex name, because that is how resolvers find out who is authoritative for the zone and what its serial number is. Those two records are not optional.

So the moment you put a CNAME at the apex, you have two rules fighting over the same name. Some DNS servers refuse to save the record at all. Others save it and then the zone stops working correctly, because the CNAME now shadows the SOA and NS answers that resolvers need, and it can shadow MX and TXT too. Either way, something breaks, and it is rarely obvious from the dashboard alone.

example.com CNAME app.host.net SOA record missing or hidden NS records missing or hidden MX / TXT shadowed by CNAME Zone breaks or record rejected SERVFAIL or lost mail
A literal CNAME at the apex collides with the mandatory SOA and NS records, and can shadow MX and TXT along the way.

Why it happens

This is a documented, long-standing rule, not a quirk of one provider. RFC 1034 section 3.6.2 spells out that a name with a CNAME cannot carry any other resource record, and ISC's own knowledgebase repeats the same warning specifically for the zone apex.

The key insight

The rule is not about the apex being special magic. It is that SOA and NS are required at the apex, and CNAME cannot coexist with anything. Those two facts collide only at the apex, because that is the one name in the zone where SOA and NS must live. Anywhere else, a CNAME is perfectly fine.

The fix, as a flow

There are two valid ways out. If the target you want to point at has a stable IP address, just use it directly with A or AAAA records at the apex. If the target's IP can change (a platform like Pages, S3, or a CDN endpoint), use your DNS provider's apex-aliasing feature, sometimes called CNAME flattening, ALIAS, or ANAME. You still configure it like a CNAME in the dashboard, but the provider resolves it on their own servers and only ever publishes real A/AAAA answers to the internet. No literal CNAME touches the wire, so SOA and NS stay intact.

Dashboard record CNAME @ -> app.pages.dev Provider resolves target server side Flattens to A / AAAA answers Published to the world (no CNAME) SOA + NS stay intact apex still valid, mail still routes
Flattening keeps the convenience of a CNAME-style config while publishing only A/AAAA answers, so the apex keeps its required SOA and NS records.

How to fix it

1

Confirm the apex actually has a literal CNAME

Query the apex directly for a CNAME answer. If one comes back, you have a real conflict on the wire, not just a dashboard label.

Terminal
check-cname.sh
dig +short CNAME example.com
# a hostname here means a literal CNAME is sitting at the apex
2

Check that SOA and NS still resolve

These two records are mandatory at the apex. If step 1 returned a CNAME and this step returns nothing, the zone is broken.

Terminal
check-soa-ns.sh
dig +short SOA example.com
dig +short NS example.com
# both must return data; empty output alongside a CNAME means a broken zone
3

Remove the literal CNAME

In your DNS provider's dashboard, delete the CNAME record at the apex, the row usually shown as "@" or the bare domain name.

4

Replace it with A/AAAA records, or a flattening ALIAS

If the target has a fixed IP address, add A (and AAAA if available) records pointing straight at it. If the target's IP can change, such as a static host or CDN endpoint, use your provider's flattening or ALIAS feature instead, entered as a CNAME-shaped record that the provider resolves for you.

DNS record
dns-records.txt
# Option A: static IP, use plain A/AAAA at the apex
Type: A     Name: @   Value: 203.0.113.10   TTL: 300
Type: AAAA  Name: @   Value: 2001:db8::10   TTL: 300

# Option B: Cloudflare CNAME flattening (auto-flattened to A/AAAA)
Type: CNAME Name: @   Value: your-app.pages.dev   TTL: Auto
# Cloudflare resolves your-app.pages.dev server side and returns A/AAAA to the world

# Option C: AWS Route 53 Alias record
Type: A (Alias) Name: example.com   Alias target: your-alb-or-cloudfront-endpoint
Only on a provider that supports it

Never leave a plain CNAME type record at the bare root name on a provider that does not explicitly support flattening or ALIAS. It will conflict with the mandatory SOA and NS records. Check your provider's docs for the exact feature name before you rely on it.

How to check it worked

Re-run the same queries. A healthy apex either has no CNAME on the wire at all, or the provider's flattening quietly turned it into A/AAAA answers, and SOA, NS, MX, and TXT all still resolve.

Terminal
verify.sh
dig +short CNAME example.com
# expect: nothing, on a provider doing flattening (or on a plain A/AAAA setup)

dig +short A example.com
dig +short AAAA example.com
# expect: one or more IP addresses

dig +short SOA example.com
dig +short NS example.com
# expect: both return the zone's SOA and nameserver data, proving apex integrity

dig +short MX example.com
dig +short TXT example.com
# expect: your mail and verification records still show up, not shadowed

dig +trace example.com
# expect: the chain resolves cleanly to an A/AAAA answer, no SERVFAIL along the way

The full code

Here is a small script in each language that checks the apex for a CNAME conflict, and, when told to, repairs it through the Cloudflare API. It stays in dry run by default so it only reports what it would change.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

apex_cname_conflict.py
"""Detect a literal CNAME at a zone apex and optionally repair it via Cloudflare.
Safe by default. Set DRY_RUN=false to let it write.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("apex_cname_conflict")

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CF_API = "https://api.cloudflare.com/client/v4"


def classify_apex_cname_conflict(apex_records):
    """Pure decision function. No I/O.

    apex_records looks like:
      {"CNAME": ["target.example.net"], "NS": [...], "SOA": [...], "A": []}

    Returns one of:
      "ok"                    - no CNAME present, NS and SOA present
      "conflict_literal_cname" - CNAME present and NS/SOA missing or empty
      "flattened_ok"           - CNAME configured upstream but A/AAAA plus
                                  NS/SOA are intact (provider flattening works)
    """
    cname = apex_records.get("CNAME") or []
    ns = apex_records.get("NS") or []
    soa = apex_records.get("SOA") or []
    a = apex_records.get("A") or []
    aaaa = apex_records.get("AAAA") or []

    if not cname:
        if ns and soa:
            return "ok"
        return "conflict_literal_cname"

    if ns and soa and (a or aaaa):
        return "flattened_ok"

    return "conflict_literal_cname"


def query_apex_records(domain):
    """Query CNAME, A, AAAA, NS, and SOA at the zone apex. Requires network."""
    import dns.resolver

    resolver = dns.resolver.Resolver()
    records = {"CNAME": [], "A": [], "AAAA": [], "NS": [], "SOA": []}
    for rtype in records:
        try:
            answer = resolver.resolve(domain, rtype)
            records[rtype] = [str(r) for r in answer]
        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
            records[rtype] = []
        except Exception as exc:
            log.warning("Query for %s %s failed: %s", domain, rtype, exc)
            records[rtype] = []
    return records


def find_apex_cname_record_id(domain):
    """Find the offending CNAME record via the Cloudflare API."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
    r = requests.get(url, headers=headers, params={"type": "CNAME", "name": domain}, timeout=30)
    r.raise_for_status()
    result = r.json().get("result", [])
    return result[0]["id"] if result else None


def replace_apex_cname(domain, record_id, replacement_ip):
    """Delete the literal apex CNAME and create an A record instead."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}", "Content-Type": "application/json"}
    base = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records"

    if DRY_RUN:
        log.info("[dry run] would delete CNAME record %s at %s", record_id, domain)
        log.info("[dry run] would create A record %s -> %s", domain, replacement_ip)
        return

    requests.delete(f"{base}/{record_id}", headers=headers, timeout=30).raise_for_status()
    requests.post(
        base,
        headers=headers,
        json={"type": "A", "name": domain, "content": replacement_ip, "ttl": 300, "proxied": False},
        timeout=30,
    ).raise_for_status()
    log.info("Replaced apex CNAME with A record %s -> %s", domain, replacement_ip)


def run():
    records = query_apex_records(DNS_DOMAIN)
    verdict = classify_apex_cname_conflict(records)
    log.info("Apex %s classified as: %s", DNS_DOMAIN, verdict)

    if verdict != "conflict_literal_cname":
        log.info("Nothing to repair.")
        return

    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning("Conflict found but no Cloudflare credentials set. Skipping repair.")
        return

    record_id = find_apex_cname_record_id(DNS_DOMAIN)
    if not record_id:
        log.warning("Could not find the CNAME record via the Cloudflare API.")
        return

    replacement_ip = os.environ.get("REPLACEMENT_IP", "203.0.113.10")
    replace_apex_cname(DNS_DOMAIN, record_id, replacement_ip)


if __name__ == "__main__":
    run()
apex-cname-conflict.js
/**
 * Detect a literal CNAME at a zone apex and optionally repair it via Cloudflare.
 * Safe by default. Set DRY_RUN=false to let it write.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";

/**
 * Pure decision function. No I/O.
 *
 * apexRecords looks like:
 *   { CNAME: ["target.example.net"], NS: [...], SOA: [...], A: [] }
 *
 * Returns one of:
 *   "ok"                     - no CNAME present, NS and SOA present
 *   "conflict_literal_cname" - CNAME present and NS/SOA missing or empty
 *   "flattened_ok"           - CNAME configured upstream but A/AAAA plus
 *                              NS/SOA are intact (provider flattening works)
 */
export function classifyApexCnameConflict(apexRecords) {
  const cname = apexRecords.CNAME || [];
  const ns = apexRecords.NS || [];
  const soa = apexRecords.SOA || [];
  const a = apexRecords.A || [];
  const aaaa = apexRecords.AAAA || [];

  if (cname.length === 0) {
    if (ns.length && soa.length) return "ok";
    return "conflict_literal_cname";
  }

  if (ns.length && soa.length && (a.length || aaaa.length)) {
    return "flattened_ok";
  }

  return "conflict_literal_cname";
}

/** Query CNAME, A, AAAA, NS, and SOA at the zone apex. Requires network. */
async function queryApexRecords(domain) {
  const dns = await import("node:dns/promises");
  const types = ["CNAME", "A", "AAAA", "NS", "SOA"];
  const records = {};
  for (const type of types) {
    try {
      const answer = await dns.resolve(domain, type);
      records[type] = answer.map((r) => (typeof r === "string" ? r : JSON.stringify(r)));
    } catch {
      records[type] = [];
    }
  }
  return records;
}

/** Find the offending CNAME record via the Cloudflare API. */
async function findApexCnameRecordId(domain) {
  const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=CNAME&name=${domain}`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } });
  if (!res.ok) throw new Error(`Cloudflare list failed: ${res.status}`);
  const body = await res.json();
  const result = body.result || [];
  return result.length ? result[0].id : null;
}

/** Delete the literal apex CNAME and create an A record instead. */
async function replaceApexCname(domain, recordId, replacementIp) {
  const headers = {
    Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
    "Content-Type": "application/json",
  };
  const base = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records`;

  if (DRY_RUN) {
    console.log(`[dry run] would delete CNAME record ${recordId} at ${domain}`);
    console.log(`[dry run] would create A record ${domain} -> ${replacementIp}`);
    return;
  }

  const del = await fetch(`${base}/${recordId}`, { method: "DELETE", headers });
  if (!del.ok) throw new Error(`Cloudflare delete failed: ${del.status}`);

  const create = await fetch(base, {
    method: "POST",
    headers,
    body: JSON.stringify({ type: "A", name: domain, content: replacementIp, ttl: 300, proxied: false }),
  });
  if (!create.ok) throw new Error(`Cloudflare create failed: ${create.status}`);
  console.log(`Replaced apex CNAME with A record ${domain} -> ${replacementIp}`);
}

async function run() {
  const records = await queryApexRecords(DNS_DOMAIN);
  const verdict = classifyApexCnameConflict(records);
  console.log(`Apex ${DNS_DOMAIN} classified as: ${verdict}`);

  if (verdict !== "conflict_literal_cname") {
    console.log("Nothing to repair.");
    return;
  }

  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn("Conflict found but no Cloudflare credentials set. Skipping repair.");
    return;
  }

  const recordId = await findApexCnameRecordId(DNS_DOMAIN);
  if (!recordId) {
    console.warn("Could not find the CNAME record via the Cloudflare API.");
    return;
  }

  const replacementIp = process.env.REPLACEMENT_IP || "203.0.113.10";
  await replaceApexCname(DNS_DOMAIN, recordId, replacementIp);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Add a test

The classification rule is the part worth testing, because it decides whether the script thinks your zone is broken. It takes plain dictionaries, no network, no DNS lookups, so the tests run in milliseconds.

test_apex_classify.py
from apex_cname_conflict import classify_apex_cname_conflict


def test_ok_when_no_cname_and_ns_soa_present():
    records = {"CNAME": [], "NS": ["ns1.example.com"], "SOA": ["soa data"], "A": ["203.0.113.10"]}
    assert classify_apex_cname_conflict(records) == "ok"


def test_conflict_when_cname_present_and_ns_soa_missing():
    records = {"CNAME": ["target.example.net"], "NS": [], "SOA": [], "A": []}
    assert classify_apex_cname_conflict(records) == "conflict_literal_cname"


def test_flattened_ok_when_cname_upstream_but_a_and_ns_soa_intact():
    records = {
        "CNAME": ["target.example.net"],
        "NS": ["ns1.example.com"],
        "SOA": ["soa data"],
        "A": ["203.0.113.10"],
    }
    assert classify_apex_cname_conflict(records) == "flattened_ok"


def test_conflict_when_cname_present_and_no_a_or_aaaa():
    records = {"CNAME": ["target.example.net"], "NS": ["ns1.example.com"], "SOA": ["soa data"], "A": []}
    assert classify_apex_cname_conflict(records) == "conflict_literal_cname"
apex-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyApexCnameConflict } from "./apex-cname-conflict.js";

test("ok when no cname and ns/soa present", () => {
  const records = { CNAME: [], NS: ["ns1.example.com"], SOA: ["soa data"], A: ["203.0.113.10"] };
  assert.equal(classifyApexCnameConflict(records), "ok");
});

test("conflict when cname present and ns/soa missing", () => {
  const records = { CNAME: ["target.example.net"], NS: [], SOA: [], A: [] };
  assert.equal(classifyApexCnameConflict(records), "conflict_literal_cname");
});

test("flattened ok when cname upstream but a and ns/soa intact", () => {
  const records = {
    CNAME: ["target.example.net"],
    NS: ["ns1.example.com"],
    SOA: ["soa data"],
    A: ["203.0.113.10"],
  };
  assert.equal(classifyApexCnameConflict(records), "flattened_ok");
});

test("conflict when cname present and no a or aaaa", () => {
  const records = { CNAME: ["target.example.net"], NS: ["ns1.example.com"], SOA: ["soa data"], A: [] };
  assert.equal(classifyApexCnameConflict(records), "conflict_literal_cname");
});

Case studies

Static site launch

The domain that would not save

A team followed a static site host's quick-start guide, which told everyone to "add a CNAME pointing to our platform," and pasted it onto the bare apex row in their registrar's basic DNS panel. The provider rejected the save outright, because it does not support flattening, and gave a generic error with no explanation of why.

Switching to an A record pointing at the platform's fixed IP address, listed further down the same host's docs, fixed it in minutes.

Mail outage

The migration that silently ate the MX records

During a DNS provider migration, an apex CNAME copied over from the old zone. The old provider had been flattening it invisibly. The new provider took the record literally, and the zone's MX and TXT records for email stopped being served. Inbound mail started bouncing within hours.

Running the check against SOA, NS, MX, and TXT at the apex showed the CNAME sitting alone with everything else missing. Replacing it with A/AAAA records restored mail delivery immediately.

What good looks like

A healthy apex has no literal CNAME on the wire, ever. It answers with A and/or AAAA records for the site, SOA and NS records for the zone itself, and MX and TXT records for mail and verification, all at the same bare domain name, all resolving cleanly with no SERVFAIL along the way.

FAQ

Why can't I put a CNAME on my bare domain?

The rule comes from RFC 1034. If a name has a CNAME record, no other record type can exist at that same name. But the zone apex, the bare domain itself, is required to hold SOA and NS records. A literal CNAME at the apex either gets rejected by the server or breaks the zone because SOA, NS, MX, and TXT records at that name become invalid.

What is CNAME flattening and is it the same as a real CNAME?

No. CNAME flattening is a provider-side feature. You configure a CNAME-style record at the apex in the dashboard, but the provider resolves the target on its own servers and publishes plain A or AAAA answers to the world. No literal CNAME ever appears on the wire, so it does not conflict with SOA or NS.

How do I fix a CNAME that is already at my zone apex?

Remove the literal CNAME record at the apex and replace it with A/AAAA records pointing at the target's IP addresses, or use your provider's apex-aliasing feature such as Cloudflare CNAME flattening, an AWS Route 53 Alias record, or an ANAME/ALIAS record on providers that support one.

Related field notes

Citations

On the problem:

  1. RFC 1034, Domain Names, Concepts and Facilities, section 3.6.2, the CNAME rule. datatracker.ietf.org/doc/html/rfc1034
  2. ISC Knowledgebase: CNAME at the apex of a zone. kb.isc.org/docs/aa-01640
  3. Simon Painter: CNAME rules in DNS, what you need to know. simonpainter.com/cname-rules

On the solution:

  1. Cloudflare DNS docs: CNAME flattening. developers.cloudflare.com/dns/cname-flattening
  2. Cloudflare DNS docs: Set up CNAME flattening. developers.cloudflare.com/dns/cname-flattening/set-up-cname-flattening
  3. Cloudflare DNS docs: Create a zone apex record. developers.cloudflare.com/dns/manage-dns-records/how-to/create-zone-apex

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your zone?

If this saved you a mail outage or a broken launch, 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

Back to all DNS and Domains field notes