Diagnostic Nameserver / Delegation

Subdomain delegation missing NS glue in the parent zone

The child zone looks fine. It has a real SOA record, real NS records, and it answers correctly when you ask its own nameservers directly. But the subdomain still will not resolve for anyone else. The reason is almost always the same: the parent zone never got the NS records that tell the rest of the internet where to find the child. Here is how to spot that gap and close it.

Nameserver delegation dig +trace Python and Node.js
A cybersec word block
Photo by Markus Winkler on Unsplash
The short answer

A subdomain like app.example.com can have a perfectly working zone of its own and still be unreachable, because the parent zone (example.com) never added the NS records that hand off authority to the child's nameservers. Resolvers start at the parent and need that handoff, called a referral, to find the child. Add an NS record set for the subdomain name in the parent zone, pointing at the exact nameservers the child zone shows on its own overview page, and the chain reconnects. Full checks, fix, and a small script are below.

The problem in plain words

DNS works like a chain of handoffs. A resolver starts at the root, gets pointed to the nameservers for .com, gets pointed from there to the nameservers for example.com, and so on. Each step only works if the step before it points forward correctly.

A subdomain such as app.example.com is often run on a different set of nameservers than the main domain, for example a different DNS provider or a different team. That child zone can be set up perfectly: correct SOA record, correct NS records, correct A record, all live and answering. But none of that matters if the parent zone, example.com, was never told to hand off app.example.com to those nameservers. The resolver walks down from the root, reaches example.com, asks "where do I go for app," and gets nothing useful back. The child zone becomes an orphan: fully built, fully correct, and completely unreachable.

Resolver asks for app.example.com Parent zone example.com nameservers no NS records for app no referral NXDOMAIN or SERVFAIL Child zone correct SOA + NS fully live, unreached The child zone is real and answers correctly on its own nameservers. Nobody outside ever gets there, because the parent never points to it.
The resolver never gets past the parent zone, because the parent has no NS record set telling it where the child nameservers are.

Why it happens

This gap shows up in a few common ways:

RFC 1912 lists this kind of broken referral, sometimes called a lame delegation, as one of the most common operational mistakes in DNS. It is easy to create because the child zone can look completely healthy in its own dashboard while being invisible to the rest of the internet.

The key insight

A subdomain does not become reachable just because its own zone is correct. It becomes reachable when the parent zone points at it. The parent's NS record set for the subdomain name is the only thing that turns a self-contained child zone into part of the public DNS chain. If that record set is missing, empty, or wrong, nothing else you do in the child zone matters.

The fix, as a flow

The fix is a single change in the right place: add an NS record set for the subdomain name in the parent zone, pointing at the child zone's actual nameservers. Nothing in the child zone needs to change, since it was already correct. The only job is to make the parent hand off cleanly.

Query parent NS for app.example.com Query child NS + SOA at its own nameservers Parent and child NS sets agree? yes, ok no, missing delegation Add NS records in parent zone name: app, content: child nameservers
Compare what the parent delegates against what the child actually runs, and add the missing NS record set in the parent zone.

How to fix it

1

Confirm it is really a delegation gap

Trace the resolution path from the root down. A healthy delegation shows an NS record set for the subdomain right before the trace hands off to the child's nameservers. A broken one repeats the parent's own NS records, stalls, or returns NXDOMAIN at that step.

Terminal
dig +trace app.example.com
2

Compare the parent's view against the child's view

Ask the parent zone's own authoritative nameserver what it delegates for the subdomain name. Then ask the child zone's authoritative nameserver the same question. If the child answers with a real NS and SOA record set, but the parent returns nothing, an empty answer, or just its own apex NS records again, that is the signature of missing delegation glue.

Terminal
dig NS app.example.com @ns1.parent-registrar.com
dig NS app.example.com @ns1.childprovider.com
dig SOA app.example.com @1.1.1.1
3

Find the child zone's real nameservers

Open the child zone's overview page at its DNS provider. Cloudflare shows a pair of nameservers. Route 53 hosted zones show four. Copy the exact hostnames, since these are the values that need to go into the parent zone.

4

Add the NS record set in the parent zone

Go to the DNS host for the parent zone, example.com, not the registrar. Add one NS record per child nameserver, all under the same subdomain name. Do not add any other record type at that exact name in the parent zone. The delegation point should hold only NS records, and a DS record if the child zone uses DNSSEC.

DNS record
Type:    NS
Name:    app
Content: ns1.cloudflare.com

Type:    NS
Name:    app
Content: ns2.cloudflare.com
5

Add a DS record if the child uses DNSSEC

If the child zone signs its records with DNSSEC, also add a matching DS record at the parent, or at the registrar if the parent zone does not support inline DS records. Skipping this step breaks the chain of trust even after the NS records are correct, and resolvers that validate DNSSEC will refuse to answer.

How to check it worked

Re-run the trace. It should now leave example.com with a clean referral to the child's nameservers, with no NXDOMAIN or SERVFAIL along the way. Then confirm the parent and child agree on the same nameserver set, and that a public resolver finally returns the expected address. Give it time for the parent zone's NS record TTL to expire first, commonly 3600 seconds or less.

Terminal
dig +trace app.example.com
# good: a referral out of example.com to ns1.cloudflare.com / ns2.cloudflare.com,
# then a final A/AAAA answer, with no NXDOMAIN or SERVFAIL steps

dig NS app.example.com @ns1.parent-registrar.com
dig NS app.example.com @ns1.childprovider.com
# good: both return the same nameserver hostnames

dig +short A app.example.com
# good: the expected IP address, not empty and not SERVFAIL

The full code

Here is a small script that checks a parent and child zone pair for missing delegation, and can add the missing NS records through the Cloudflare API when it finds a gap. It starts in dry run mode by default.

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

check_delegation.py
"""Detect a missing subdomain delegation and repair it in the parent zone.
DETECT: query the parent zone's own authoritative nameserver for the NS
record set at the subdomain name, and query the child zone's own
authoritative nameserver for its NS and SOA records.
REPAIR: if the child is live but the parent has no matching NS records,
add the missing NS records in the parent zone through the Cloudflare API.
Safe to run again and again. Starts in dry run mode.
"""
import os
import logging

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

DNS_DOMAIN = os.environ["DNS_DOMAIN"]  # e.g. "app.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"


def is_delegation_missing(parent_ns_answer, child_ns_answer, child_soa_present):
    """
    parent_ns_answer: NS hostnames returned by the parent zone's authoritative
                       server when queried for the subdomain name (empty list
                       if NXDOMAIN/no data).
    child_ns_answer:  NS hostnames returned by the child zone's own
                       authoritative server for the same name.
    child_soa_present: True if the child zone answers with a valid SOA record
                        for the subdomain (i.e. the child zone is actually
                        configured and live).
    Returns True (delegation is missing/broken) when the child zone is
    live and has NS records, but the parent has no NS records for that
    name, or the parent's NS set shares no hostnames with the child's.
    """
    if not child_soa_present or not child_ns_answer:
        return False  # child isn't configured; not a delegation problem
    if not parent_ns_answer:
        return True   # parent has nothing delegating this name
    return len(set(parent_ns_answer) & set(child_ns_answer)) == 0


def _parent_zone_of(name):
    parts = name.rstrip(".").split(".")
    return ".".join(parts[1:])


def query_ns(name, nameserver=None):
    """Query NS records for name. If nameserver is given, ask it directly."""
    import dns.resolver

    resolver = dns.resolver.Resolver()
    if nameserver:
        resolver.nameservers = [nameserver]
    try:
        answer = resolver.resolve(name, "NS")
        return sorted(str(r.target).rstrip(".") for r in answer)
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
        return []


def query_soa_present(name, nameserver=None):
    import dns.resolver

    resolver = dns.resolver.Resolver()
    if nameserver:
        resolver.nameservers = [nameserver]
    try:
        resolver.resolve(name, "SOA")
        return True
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
        return False


def find_authoritative_ns(zone):
    import dns.resolver

    answer = dns.resolver.resolve(zone, "NS")
    return sorted(str(r.target).rstrip(".") for r in answer)


def add_delegation_records(subdomain, child_nameservers):
    """Add one NS record per child nameserver in the parent zone via Cloudflare."""
    import requests

    headers = {
        "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}",
        "Content-Type": "application/json",
    }
    for ns_host in child_nameservers:
        payload = {"type": "NS", "name": subdomain, "content": ns_host}
        if DRY_RUN:
            log.info("DRY RUN: would create NS record %s -> %s", subdomain, ns_host)
            continue
        r = requests.post(
            f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
            json=payload, headers=headers, timeout=30,
        )
        r.raise_for_status()
        log.info("Created NS record %s -> %s", subdomain, ns_host)


def run():
    subdomain = DNS_DOMAIN
    parent_zone = _parent_zone_of(subdomain)

    parent_authoritative = find_authoritative_ns(parent_zone)
    parent_ns_answer = query_ns(subdomain, nameserver=None)
    for ns_host in parent_authoritative:
        parent_ns_answer = query_ns(subdomain, nameserver=ns_host)
        break

    child_ns_answer = query_ns(subdomain)
    child_soa_present = query_soa_present(subdomain)

    if is_delegation_missing(parent_ns_answer, child_ns_answer, child_soa_present):
        log.warning(
            "Missing delegation for %s: parent has %s, child has %s",
            subdomain, parent_ns_answer, child_ns_answer,
        )
        if not CLOUDFLARE_API_TOKEN or not CLOUDFLARE_ZONE_ID:
            log.info("No Cloudflare credentials set. Skipping repair, reporting only.")
            return
        add_delegation_records(subdomain, child_ns_answer)
    else:
        log.info("Delegation looks fine for %s.", subdomain)


if __name__ == "__main__":
    run()
check-delegation.js
/**
 * Detect a missing subdomain delegation and repair it in the parent zone.
 * DETECT: query the parent zone's own authoritative nameserver for the NS
 * record set at the subdomain name, and query the child zone's own
 * authoritative nameserver for its NS and SOA records.
 * REPAIR: if the child is live but the parent has no matching NS records,
 * add the missing NS records in the parent zone through the Cloudflare API.
 * Safe to run again and again. Starts in dry run mode.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "app.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";

export function isDelegationMissing(parentNsAnswer, childNsAnswer, childSoaPresent) {
  // parentNsAnswer: NS hostnames the parent zone's authoritative server
  //                 returns for the subdomain name (empty array if none).
  // childNsAnswer:  NS hostnames the child zone's own authoritative
  //                 server returns for the same name.
  // childSoaPresent: true if the child zone answers with a valid SOA
  //                  record for the subdomain (the child is actually live).
  // Returns true when the child zone is live and has NS records, but the
  // parent has no NS records for that name, or shares none with the child.
  if (!childSoaPresent || childNsAnswer.length === 0) return false;
  if (parentNsAnswer.length === 0) return true;
  const childSet = new Set(childNsAnswer);
  return !parentNsAnswer.some((ns) => childSet.has(ns));
}

function parentZoneOf(name) {
  const parts = name.replace(/\.$/, "").split(".");
  return parts.slice(1).join(".");
}

async function queryNs(name, nameserver) {
  const dns = await import("node:dns");
  const dnsPromises = dns.promises;
  const resolver = new dnsPromises.Resolver();
  if (nameserver) resolver.setServers([nameserver]);
  try {
    const records = await resolver.resolveNs(name);
    return records.map((r) => r.replace(/\.$/, "")).sort();
  } catch {
    return [];
  }
}

async function querySoaPresent(name, nameserver) {
  const dns = await import("node:dns");
  const dnsPromises = dns.promises;
  const resolver = new dnsPromises.Resolver();
  if (nameserver) resolver.setServers([nameserver]);
  try {
    await resolver.resolveSoa(name);
    return true;
  } catch {
    return false;
  }
}

async function findAuthoritativeNs(zone) {
  const dns = await import("node:dns");
  const records = await dns.promises.resolveNs(zone);
  return records.map((r) => r.replace(/\.$/, "")).sort();
}

async function addDelegationRecords(subdomain, childNameservers) {
  const headers = {
    Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
    "Content-Type": "application/json",
  };
  for (const nsHost of childNameservers) {
    const payload = { type: "NS", name: subdomain, content: nsHost };
    if (DRY_RUN) {
      console.log(`DRY RUN: would create NS record ${subdomain} -> ${nsHost}`);
      continue;
    }
    const res = await fetch(
      `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records`,
      { method: "POST", headers, body: JSON.stringify(payload) },
    );
    if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
    console.log(`Created NS record ${subdomain} -> ${nsHost}`);
  }
}

export async function run() {
  const subdomain = DNS_DOMAIN;
  const parentZone = parentZoneOf(subdomain);

  const parentAuthoritative = await findAuthoritativeNs(parentZone);
  let parentNsAnswer = [];
  if (parentAuthoritative.length > 0) {
    parentNsAnswer = await queryNs(subdomain, parentAuthoritative[0]);
  }

  const childNsAnswer = await queryNs(subdomain);
  const childSoaPresent = await querySoaPresent(subdomain);

  if (isDelegationMissing(parentNsAnswer, childNsAnswer, childSoaPresent)) {
    console.warn(
      `Missing delegation for ${subdomain}: parent has ${JSON.stringify(parentNsAnswer)}, child has ${JSON.stringify(childNsAnswer)}`,
    );
    if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ZONE_ID) {
      console.log("No Cloudflare credentials set. Skipping repair, reporting only.");
      return;
    }
    await addDelegationRecords(subdomain, childNsAnswer);
  } else {
    console.log(`Delegation looks fine for ${subdomain}.`);
  }
}

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

Add a test

The part worth testing is the decision rule, since it decides whether the script tries to write a delegation record at all. It is a pure function, no network, so the test just feeds in plain lists and checks the answer.

test_missing_delegation.py
from check_delegation import is_delegation_missing


def test_missing_when_parent_has_nothing():
    assert is_delegation_missing([], ["ns1.cloudflare.com", "ns2.cloudflare.com"], True) is True


def test_missing_when_parent_and_child_disagree():
    parent = ["ns1.oldhost.com"]
    child = ["ns1.cloudflare.com", "ns2.cloudflare.com"]
    assert is_delegation_missing(parent, child, True) is True


def test_ok_when_parent_and_child_agree():
    parent = ["ns1.cloudflare.com", "ns2.cloudflare.com"]
    child = ["ns1.cloudflare.com", "ns2.cloudflare.com"]
    assert is_delegation_missing(parent, child, True) is False


def test_not_a_problem_when_child_not_configured():
    assert is_delegation_missing([], [], False) is False
check-delegation.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDelegationMissing } from "./check-delegation.js";

test("missing when parent has nothing", () => {
  assert.equal(isDelegationMissing([], ["ns1.cloudflare.com", "ns2.cloudflare.com"], true), true);
});

test("missing when parent and child disagree", () => {
  const parent = ["ns1.oldhost.com"];
  const child = ["ns1.cloudflare.com", "ns2.cloudflare.com"];
  assert.equal(isDelegationMissing(parent, child, true), true);
});

test("ok when parent and child agree", () => {
  const parent = ["ns1.cloudflare.com", "ns2.cloudflare.com"];
  const child = ["ns1.cloudflare.com", "ns2.cloudflare.com"];
  assert.equal(isDelegationMissing(parent, child, true), false);
});

test("not a problem when child not configured", () => {
  assert.equal(isDelegationMissing([], [], false), false);
});

Case studies

Provider migration

The subdomain that got left behind

A company moved its main domain from one registrar's DNS to Cloudflare. The migration checklist covered the apex and www, but a subdomain used only by an internal tool, reports.example.com, was on its own zone at a different provider and never got carried over. Its zone was untouched and still correct.

Nobody noticed for weeks because the tool was mostly accessed through a VPN with cached DNS. When a new laptop tried to resolve it fresh, it got NXDOMAIN. A trace showed the parent zone had zero NS records for reports. Adding the two missing NS records at Cloudflare fixed it within the hour, once the old TTL expired.

Vendor handoff

The vendor built the zone, nobody delegated it

A marketing team hired a vendor to stand up a landing page on promo.example.com. The vendor built a complete DNS zone on their own platform and sent over the nameservers, but assumed someone on the client side had already pointed the parent zone at them.

Nobody had. The page worked fine when tested from the vendor's own network, because their tools queried their own nameservers directly. From the public internet, the domain simply did not exist. Comparing dig NS promo.example.com against the parent's authoritative server versus the vendor's nameserver made the gap obvious, and one NS record set closed it.

What good looks like

Once the parent zone carries the NS record set for the subdomain, a full trace from the root resolves cleanly, with no NXDOMAIN or SERVFAIL along the way. Anyone on the public internet, not just people on the vendor's network or with a warm DNS cache, gets the right answer. Whenever a subdomain moves to a new set of nameservers, treat updating the parent's NS records as part of that move, not an afterthought.

FAQ

Why does my subdomain not resolve even though its nameservers are set up correctly?

The child zone can be fully correct and still be unreachable if the parent zone never added the NS records that hand off authority for that subdomain name. Resolvers start at the parent and need a referral to find the child nameservers. Without that referral, the child zone is orphaned.

Is this the same thing as registrar glue records?

No. Registrar glue is the A or AAAA record the registry keeps for an in-bailiwick nameserver hostname, like an IP address for ns1.example.com. Delegation glue for a subdomain is an NS record set that lives inside the parent zone itself, added at your DNS host, not at the registrar.

What other records can I put at the exact subdomain name in the parent zone?

None. Once you add NS records at a name, that name becomes a delegation point and DNS rules say it should carry only those NS records, plus a DS record if you use DNSSEC. Any other record type at that exact name is invalid and will confuse resolvers.

Related field notes

Citations

On the problem:

  1. RFC 1912: Common DNS Operational and Configuration Errors. rfc-editor.org/rfc/rfc1912
  2. DNS zone delegation, explained. nslookup.io/learning/zone-delegation
  3. DNS Lame Delegations Report. dnsinstitute.com/research/lame-servers-201911

On the solution:

  1. Cloudflare docs: delegate subdomains to nameservers outside Cloudflare. developers.cloudflare.com/dns
  2. AWS Route 53 docs: creating a subdomain that uses Route 53 without migrating the parent domain. docs.aws.amazon.com/Route53
  3. AWS re:Post: test if your delegated subdomain resolves. repost.aws/knowledge-center

Stuck on a tricky one?

If you have a DNS, delegation, 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 delegation problem?

If this saved you an afternoon of staring at dig output, 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