Diagnostic Provider API / Reconciliation

Zone lookup fails to resolve the correct managed zone

Your certificate request fails with an error like "could not determine the zone" or "NXDOMAIN looking up TXT record." Nothing about your certificate config looks wrong. The real problem is one level down: the ACME client walked up your domain name looking for the right DNS zone, landed on an apex, and that apex is not the exact zone name your DNS provider account has on file. Here is why that walk goes wrong, and how to make the two agree.

Python and Node.js dnspython + Cloudflare API Safe by default (dry run)
Using a laptop
Photo by Henrik L. on Unsplash
The short answer

ACME DNS-01 clients (lego, which powers cert-manager, Certbot's DNS plugins, and Traefik) do not know ahead of time which zone in your account owns a name. They strip labels off the challenge name one at a time, asking for an SOA record at each level, and treat the first apex that answers as "the zone." This breaks when a subdomain is delegated to a different provider or zone than its parent, when a broken level returns SERVFAIL or NXDOMAIN instead of a clean no-SOA answer, or when the apex the walk finds is not the exact zone name registered with your provider's API. The fix is to make the DNS-side authority and the provider account's zone list agree, either by properly delegating the subdomain or by removing a stray delegation, so the walk and the API land on the same name.

The problem in plain words

When an ACME client needs to prove you control a domain using DNS-01, it has to write a TXT record at _acme-challenge.yourname. To do that it first has to figure out which zone in your DNS provider account it should write into. It cannot just guess, because your account might have a zone for example.com, or for sub.example.com, or both.

So the client walks up the name. Starting at _acme-challenge.www.sub.example.com., it strips off one label and asks for an SOA record, then strips another label and asks again, all the way up to the registered domain. The first level that answers with an SOA is treated as the zone apex. That part happens purely on the wire, using plain DNS queries.

The trouble starts after that. The client then takes the apex it found and asks your DNS provider's API for a zone with that exact name. If sub.example.com has its own SOA because it is delegated somewhere else, but your provider account only has a zone called example.com, the API lookup comes up empty. The walk found a real apex on the wire, but it is not a zone name the provider account recognizes, and the client has no way to reconcile the two on its own.

Walk up the labels asking SOA at each level SOA found at sub.example.com. Ask provider API zones?name=sub.example.com no such zone Account only has a zone named example.com Could not determine the zone TXT record never written
The SOA walk finds a real apex on the wire. The provider API lookup for that exact name comes back empty because the account's zone list disagrees with what DNS actually serves.

Why it happens

The walk itself is simple and almost always works. What breaks it is a mismatch between what DNS answers on the wire and what your provider account has registered as a zone. A few common causes:

This shows up constantly with cert-manager, Certbot's DNS plugins, Traefik, and anything else built on the lego library's FindZoneByFqdn, since they all use the same label-walk approach. See the citations at the end for the exact issue threads.

The key insight

There is no DNS record that fixes the walk itself. The walk is only ever a mirror of what is actually delegated on the wire. The fix is to make the DNS-side authority (who really answers SOA for a name) and the provider-account zone list (what your API key can see) describe the exact same zone. Once they agree, the walk and the lookup both land in the same place automatically.

The fix, as a flow

First figure out which apex the wire actually serves, using the same label walk the ACME client does. Then check whether your DNS provider account has a zone registered under that exact name. If the subdomain is meant to be its own zone, delegate it properly and add it to the account the ACME client uses. If the delegation was accidental, remove it so the walk falls through to the parent. Only after DNS and the provider account agree should you rerun the ACME client.

Confirm true apex dig SOA at each label Check provider API zones?name= that apex Meant to be its own zone? yes Delegate and register that zone no, accidental Remove stray NS records Rerun ACME
Once the DNS-side apex and the provider account's zone list name the same thing, the walk and the API lookup agree, and the TXT record writes without needing to hardcode anything.

How to fix it

1

Replay the label walk yourself with dig

Start at the full challenge name and strip one label at a time, checking for an SOA at each level. A healthy zone gives an empty answer at every level except the one true apex, which returns exactly one SOA record.

Terminal
walk-the-labels.sh
dig SOA _acme-challenge.www.sub.example.com. +noall +answer
dig SOA www.sub.example.com. +noall +answer
dig SOA sub.example.com. +noall +answer
dig SOA example.com. +noall +answer

# Healthy result: every level is empty except one, which returns
# exactly one SOA record, e.g.:
# sub.example.com.  3600  IN  SOA  ns1.provider.com. admin.example.com. ...
#
# Bad result: no level ever returns an SOA (broken delegation), or two
# different levels both answer with an SOA (unexpected extra delegation)
2

Confirm whether the subdomain is really delegated

Compare the NS records for the subdomain against the parent. If they differ, the subdomain has its own delegation and is its own zone, whether that was intentional or not.

Terminal
check-delegation.sh
dig NS sub.example.com. +noall +answer
dig NS example.com. +noall +answer

# If sub.example.com has its own NS set that is different from
# example.com's NS set, sub.example.com is delegated as its own zone.
3

Check which name is actually registered in the provider account

Ask the DNS provider's API directly for a zone with the exact apex name the walk found, and also for the parent, to see which one it recognizes.

Terminal
check-provider-zones.sh
curl -s "https://api.cloudflare.com/client/v4/zones?name=sub.example.com" \
  -H "Authorization: Bearer $CF_TOKEN" | jq '.result[].name'

curl -s "https://api.cloudflare.com/client/v4/zones?name=example.com" \
  -H "Authorization: Bearer $CF_TOKEN" | jq '.result[].name'

# Whichever call returns a non-empty result array is the zone actually
# registered in this Cloudflare account.
4

If the subdomain should be its own zone, delegate it properly

Create a real zone for the subdomain in the DNS provider account used by the ACME client, and delegate to it from the parent zone with NS records pointing at that provider's assigned nameservers.

DNS record
parent zone delegation for sub.example.com
; Create zone "sub.example.com" in Cloudflare, then delegate to it
; from the parent zone (example.com) with these NS records:
sub.example.com.  NS  ns1.cloudflare.com.
sub.example.com.  NS  ns2.cloudflare.com.
5

If the delegation was accidental, remove it

Delete the stray NS records for the subdomain from the parent zone. That lets the label walk fall straight through the subdomain and land on the parent's SOA instead, which then matches what the provider account already has registered.

6

Where supported, pin the zone instead of relying on autodetection

Some DNS-01 solvers let you skip the walk entirely and hardcode the zone. This sidesteps the whole problem for that one client, though it does not fix the underlying DNS-versus-provider disagreement.

Terminal
pin-the-zone.sh
# lego / Certbot dns-cloudflare plugin: pass the zone explicitly
certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  --dns-cloudflare-zone example.com \
  -d sub.example.com

# Some providers also support a fixed zone id env var instead of a walk
export CF_ZONE_ID="the-known-good-zone-id"

How to check it worked

Re-run the label walk end to end, confirm NS agreement, confirm the provider API resolves the apex, then re-trigger the actual certificate request.

Terminal
verify.sh
# 1. The walk now returns exactly one SOA, at the correct apex,
#    with no SERVFAIL/NXDOMAIN at any intermediate label
dig SOA _acme-challenge.sub.example.com. +noall +answer

# 2. NS agreement: the subdomain's nameservers match the provider
#    that actually hosts that zone
dig NS sub.example.com. +short

# 3. The provider API resolves the exact apex name
curl -s "https://api.cloudflare.com/client/v4/zones?name=sub.example.com" \
  -H "Authorization: Bearer $CF_TOKEN" | jq '.result[].name'
# expected output: "sub.example.com"

# 4. Rerun the ACME client (certbot, or trigger the cert-manager
#    Certificate/Order), then confirm the TXT record landed
dig TXT _acme-challenge.sub.example.com. +short
# expected: the challenge token string, not empty

# 5. Confirm the certificate resource itself is happy
kubectl get certificate my-cert -o jsonpath='{.status.conditions}'
# expected: type Ready, status True (cert-manager)

A good result looks like this: the SOA walk gives one clean answer at the true apex, that apex's nameservers match what the provider actually hosts, the provider's zone API returns that exact name, the TXT record shows the challenge token, and the order or certificate resource reports Ready or valid instead of the previous "could not determine the zone" error.

The full code

A script can replicate the label walk itself with dnspython, querying for an SOA at each label suffix of the FQDN and stopping at the first authoritative answer, to detect the true DNS-side zone apex. It then calls Cloudflare's zones API to check whether that exact apex is registered as a zone in the account. If the walk's apex and the API's registered zone name disagree, or the API call comes back empty, the script reports the mismatch and falls back to the closest suffix the API does recognize before writing the _acme-challenge TXT record.

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

resolve_acme_zone.py
"""Detect (and, where safe, repair) a zone lookup failure for an ACME
DNS-01 challenge name. Replicates the label walk with dnspython to find
the true DNS-side apex, then checks whether that exact name is a zone
registered in the Cloudflare account. If they disagree, this reports the
mismatch. If DRY_RUN is false and a usable zone was found, it writes the
_acme-challenge TXT record through the Cloudflare API.
"""
import os
import logging

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

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "sub.example.com")
CHALLENGE_VALUE = os.environ.get("CHALLENGE_VALUE", "placeholder-token")
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 fqdn_labels(fqdn):
    """Split a name into labels, most-specific first, e.g.
    "sub.example.com" -> ["sub", "example", "com"]."""
    return [label for label in fqdn.strip(".").split(".") if label]


def candidate_suffixes(labels):
    """All suffixes of the label list, shortest-label-stripped-first,
    e.g. ["_acme-challenge","sub","example","com"] ->
    ["sub.example.com", "example.com", "com"] (drop the challenge label
    itself, the walk only ever tests real domain suffixes)."""
    suffixes = []
    for i in range(1, len(labels)):
        suffixes.append(".".join(labels[i:]))
    return suffixes


def resolve_zone_for_challenge(fqdn_labels, soa_present_at, api_zone_names):
    """
    fqdn_labels: labels of the challenge name from most-specific to root,
                 e.g. ["_acme-challenge","www","sub","example","com"]
    soa_present_at: maps a candidate zone apex string (e.g. "sub.example.com")
                     to whether a live DNS query returned an authoritative SOA there
    api_zone_names: set of zone names the DNS provider account actually has registered
    Returns the zone apex to write records into, or None if walk and API zone list
    never agree (i.e. the failure this issue describes).
    Pure decision logic: walk suffixes shortest-label-stripped-first, find first
    suffix with soa_present_at[suffix] True, then require that suffix to also be
    in api_zone_names; else return None.
    """
    for suffix in candidate_suffixes(fqdn_labels):
        if soa_present_at.get(suffix):
            return suffix if suffix in api_zone_names else None
    return None


def query_soa_present_at(suffixes):
    """Live DNS side: for each candidate suffix, ask for an SOA record and
    record whether an authoritative answer came back."""
    import dns.resolver
    import dns.exception

    present = {}
    for suffix in suffixes:
        try:
            dns.resolver.resolve(suffix, "SOA")
            present[suffix] = True
        except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
            present[suffix] = False
        except Exception:
            present[suffix] = False
    return present


def fetch_cloudflare_zone_names(candidate_names):
    """Provider side: ask Cloudflare's zone list for each candidate name and
    collect which ones actually exist in this account."""
    import requests

    found = set()
    for name in candidate_names:
        r = requests.get(
            "https://api.cloudflare.com/client/v4/zones",
            params={"name": name},
            headers={"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"},
            timeout=15,
        )
        r.raise_for_status()
        for zone in r.json().get("result", []):
            found.add(zone["name"])
    return found


def write_acme_txt_record(zone_id, challenge_fqdn, value):
    """Guarded by DRY_RUN. Writes the _acme-challenge TXT record through
    the Cloudflare DNS API once a usable zone has been confirmed."""
    import requests

    r = requests.post(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
        json={"type": "TXT", "name": challenge_fqdn, "content": value, "ttl": 120},
        headers={"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"},
        timeout=15,
    )
    r.raise_for_status()
    return r.json()


def run():
    challenge_fqdn = f"_acme-challenge.{DNS_DOMAIN}"
    labels = fqdn_labels(challenge_fqdn)
    suffixes = candidate_suffixes(labels)

    log.info("Walking labels for %s: candidates %s", challenge_fqdn, suffixes)

    soa_present_at = query_soa_present_at(suffixes)
    api_zone_names = fetch_cloudflare_zone_names(suffixes)

    log.info("SOA present at: %s", {k: v for k, v in soa_present_at.items() if v})
    log.info("Provider account zone names found: %s", sorted(api_zone_names))

    zone = resolve_zone_for_challenge(labels, soa_present_at, api_zone_names)

    if zone is None:
        log.warning(
            "Could not determine the zone for %s. The SOA walk and the provider "
            "account's zone list never agreed on an apex. Check delegation with "
            "dig NS, and confirm which name is actually registered with the "
            "provider API.",
            challenge_fqdn,
        )
        return

    log.info("Resolved zone: %s", zone)

    if DRY_RUN:
        log.info("DRY_RUN is true. Would write TXT record %s in zone %s.", challenge_fqdn, zone)
        return

    if not CLOUDFLARE_ZONE_ID:
        log.warning("DRY_RUN is false but CLOUDFLARE_ZONE_ID is not set. Not writing.")
        return

    write_acme_txt_record(CLOUDFLARE_ZONE_ID, challenge_fqdn, CHALLENGE_VALUE)
    log.info("Wrote TXT record %s in zone %s.", challenge_fqdn, zone)


if __name__ == "__main__":
    run()
resolve-acme-zone.js
/**
 * Detect (and, where safe, repair) a zone lookup failure for an ACME
 * DNS-01 challenge name. Replicates the label walk with Node's built-in
 * dns module to find the true DNS-side apex, then checks whether that
 * exact name is a zone registered in the Cloudflare account. If they
 * disagree, this reports the mismatch. If DRY_RUN is false and a usable
 * zone was found, it writes the _acme-challenge TXT record through the
 * Cloudflare API.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "sub.example.com";
const CHALLENGE_VALUE = process.env.CHALLENGE_VALUE || "placeholder-token";
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 fqdnLabels(fqdn) {
  // Split a name into labels, most-specific first, e.g.
  // "sub.example.com" -> ["sub", "example", "com"]
  return fqdn.replace(/\.$/, "").split(".").filter(Boolean);
}

export function candidateSuffixes(labels) {
  // All suffixes of the label list, shortest-label-stripped-first, e.g.
  // ["_acme-challenge","sub","example","com"] ->
  // ["sub.example.com", "example.com", "com"]
  const suffixes = [];
  for (let i = 1; i < labels.length; i++) {
    suffixes.push(labels.slice(i).join("."));
  }
  return suffixes;
}

/**
 * fqdnLabels: labels of the challenge name from most-specific to root,
 *             e.g. ["_acme-challenge","www","sub","example","com"]
 * soaPresentAt: maps a candidate zone apex string (e.g. "sub.example.com")
 *               to whether a live DNS query returned an authoritative SOA there
 * apiZoneNames: set of zone names the DNS provider account actually has registered
 * Returns the zone apex to write records into, or null if walk and API zone
 * list never agree (i.e. the failure this issue describes).
 * Pure decision logic: walk suffixes shortest-label-stripped-first, find first
 * suffix with soaPresentAt[suffix] true, then require that suffix to also be
 * in apiZoneNames; else return null.
 */
export function resolveZoneForChallenge(labels, soaPresentAt, apiZoneNames) {
  for (const suffix of candidateSuffixes(labels)) {
    if (soaPresentAt[suffix]) {
      return apiZoneNames.has(suffix) ? suffix : null;
    }
  }
  return null;
}

async function querySoaPresentAt(suffixes) {
  // Live DNS side: for each candidate suffix, ask for an SOA record and
  // record whether an authoritative answer came back.
  const dns = await import("node:dns");
  const resolveSoa = (name) =>
    new Promise((resolve) => {
      dns.resolveSoa(name, (err) => resolve(!err));
    });

  const present = {};
  for (const suffix of suffixes) {
    present[suffix] = await resolveSoa(suffix);
  }
  return present;
}

async function fetchCloudflareZoneNames(candidateNames) {
  // Provider side: ask Cloudflare's zone list for each candidate name and
  // collect which ones actually exist in this account.
  const found = new Set();
  for (const name of candidateNames) {
    const res = await fetch(
      `https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(name)}`,
      { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } }
    );
    if (!res.ok) throw new Error(`Cloudflare zone lookup failed: ${res.status}`);
    const data = await res.json();
    for (const zone of data.result || []) found.add(zone.name);
  }
  return found;
}

async function writeAcmeTxtRecord(zoneId, challengeFqdn, value) {
  // Guarded by DRY_RUN. Writes the _acme-challenge TXT record through the
  // Cloudflare DNS API once a usable zone has been confirmed.
  const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ type: "TXT", name: challengeFqdn, content: value, ttl: 120 }),
  });
  if (!res.ok) throw new Error(`Cloudflare TXT write failed: ${res.status}`);
  return res.json();
}

export async function run() {
  const challengeFqdn = `_acme-challenge.${DNS_DOMAIN}`;
  const labels = fqdnLabels(challengeFqdn);
  const suffixes = candidateSuffixes(labels);

  console.log(`Walking labels for ${challengeFqdn}: candidates ${JSON.stringify(suffixes)}`);

  const soaPresentAt = await querySoaPresentAt(suffixes);
  const apiZoneNames = await fetchCloudflareZoneNames(suffixes);

  console.log("SOA present at:", Object.fromEntries(Object.entries(soaPresentAt).filter(([, v]) => v)));
  console.log("Provider account zone names found:", [...apiZoneNames].sort());

  const zone = resolveZoneForChallenge(labels, soaPresentAt, apiZoneNames);

  if (zone === null) {
    console.warn(
      `Could not determine the zone for ${challengeFqdn}. The SOA walk and the ` +
      "provider account's zone list never agreed on an apex. Check delegation " +
      "with dig NS, and confirm which name is actually registered with the " +
      "provider API."
    );
    return;
  }

  console.log(`Resolved zone: ${zone}`);

  if (DRY_RUN) {
    console.log(`DRY_RUN is true. Would write TXT record ${challengeFqdn} in zone ${zone}.`);
    return;
  }

  if (!CLOUDFLARE_ZONE_ID) {
    console.warn("DRY_RUN is false but CLOUDFLARE_ZONE_ID is not set. Not writing.");
    return;
  }

  await writeAcmeTxtRecord(CLOUDFLARE_ZONE_ID, challengeFqdn, CHALLENGE_VALUE);
  console.log(`Wrote TXT record ${challengeFqdn} in zone ${zone}.`);
}

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

Add a test

The zone-resolving decision is the part worth testing on its own, since it decides whether the reconciler ever attempts a write. It takes plain labels, a plain map of SOA presence, and a plain set of zone names, with no network involved.

test_zone_walk.py
from resolve_acme_zone import fqdn_labels, resolve_zone_for_challenge


LABELS = fqdn_labels("_acme-challenge.www.sub.example.com")


def test_resolves_to_parent_when_only_parent_has_soa_and_api_zone():
    soa = {"www.sub.example.com": False, "sub.example.com": False, "example.com": True, "com": False}
    api_zones = {"example.com"}
    assert resolve_zone_for_challenge(LABELS, soa, api_zones) == "example.com"


def test_resolves_to_delegated_subdomain_when_registered():
    soa = {"www.sub.example.com": False, "sub.example.com": True, "example.com": True, "com": False}
    api_zones = {"sub.example.com"}
    assert resolve_zone_for_challenge(LABELS, soa, api_zones) == "sub.example.com"


def test_none_when_soa_apex_not_in_provider_account():
    # Walk finds sub.example.com, but the account only has example.com.
    soa = {"www.sub.example.com": False, "sub.example.com": True, "example.com": True, "com": False}
    api_zones = {"example.com"}
    assert resolve_zone_for_challenge(LABELS, soa, api_zones) is None


def test_none_when_no_level_ever_answers_with_soa():
    soa = {"www.sub.example.com": False, "sub.example.com": False, "example.com": False, "com": False}
    api_zones = {"example.com"}
    assert resolve_zone_for_challenge(LABELS, soa, api_zones) is None


def test_stops_at_the_first_suffix_with_soa_not_a_later_one():
    # Both sub.example.com and example.com answer with SOA; the walk must
    # stop at the first (most specific) one, not skip ahead.
    soa = {"www.sub.example.com": False, "sub.example.com": True, "example.com": True, "com": False}
    api_zones = {"example.com", "sub.example.com"}
    assert resolve_zone_for_challenge(LABELS, soa, api_zones) == "sub.example.com"


def test_empty_provider_zone_set_is_always_none():
    soa = {"www.sub.example.com": False, "sub.example.com": False, "example.com": True, "com": False}
    assert resolve_zone_for_challenge(LABELS, soa, set()) is None
zone-walk.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { fqdnLabels, resolveZoneForChallenge } from "./resolve-acme-zone.js";

const LABELS = fqdnLabels("_acme-challenge.www.sub.example.com");

test("resolves to parent when only parent has soa and api zone", () => {
  const soa = { "www.sub.example.com": false, "sub.example.com": false, "example.com": true, com: false };
  const apiZones = new Set(["example.com"]);
  assert.equal(resolveZoneForChallenge(LABELS, soa, apiZones), "example.com");
});

test("resolves to delegated subdomain when registered", () => {
  const soa = { "www.sub.example.com": false, "sub.example.com": true, "example.com": true, com: false };
  const apiZones = new Set(["sub.example.com"]);
  assert.equal(resolveZoneForChallenge(LABELS, soa, apiZones), "sub.example.com");
});

test("null when soa apex not in provider account", () => {
  const soa = { "www.sub.example.com": false, "sub.example.com": true, "example.com": true, com: false };
  const apiZones = new Set(["example.com"]);
  assert.equal(resolveZoneForChallenge(LABELS, soa, apiZones), null);
});

test("null when no level ever answers with soa", () => {
  const soa = { "www.sub.example.com": false, "sub.example.com": false, "example.com": false, com: false };
  const apiZones = new Set(["example.com"]);
  assert.equal(resolveZoneForChallenge(LABELS, soa, apiZones), null);
});

test("stops at the first suffix with soa, not a later one", () => {
  const soa = { "www.sub.example.com": false, "sub.example.com": true, "example.com": true, com: false };
  const apiZones = new Set(["example.com", "sub.example.com"]);
  assert.equal(resolveZoneForChallenge(LABELS, soa, apiZones), "sub.example.com");
});

test("empty provider zone set is always null", () => {
  const soa = { "www.sub.example.com": false, "sub.example.com": false, "example.com": true, com: false };
  assert.equal(resolveZoneForChallenge(LABELS, soa, new Set()), null);
});

Case studies

cert-manager

A staging subdomain that quietly became its own zone

A team had delegated staging.example.com to a separate Cloudflare account during an old project, then forgot about it. Months later, cert-manager tried to issue a certificate for app.staging.example.com and failed every time with "could not determine the zone," even though the main example.com zone in the primary account looked completely fine.

Running the label walk showed an SOA at staging.example.com, not at example.com. That apex was not registered in the primary Cloudflare account cert-manager's API token could see. Removing the old NS delegation for staging from the parent zone let the walk fall through to example.com, and the certificate issued on the next attempt.

Certbot dns-cloudflare

A wildcard request that hit an intermediate SERVFAIL

A store was requesting a wildcard certificate for *.shop.example.com. One label in the walk, shop.example.com, had a half-broken NS delegation left over from a DNS migration and returned SERVFAIL instead of a clean empty answer, which some resolvers treat differently than NXDOMAIN.

Certbot's dns-cloudflare plugin gave up with an NXDOMAIN-style error looking up the TXT record. Fixing the broken delegation at shop.example.com so it returned a proper empty (no SOA) answer let the walk continue cleanly up to example.com, and the same command succeeded without any other change.

What good looks like

Once the DNS-side apex and the DNS provider account's zone list agree on the same name, the ACME client's zone walk and the provider API lookup land in the same place every time, with no hardcoding required. New certificate requests for that domain and its subdomains succeed on the first try, and "could not determine the zone" stops appearing in the logs.

FAQ

Why does my ACME client say it could not determine the zone?

DNS-01 clients like lego, cert-manager, and Certbot do not know in advance which zone owns your domain name. They walk up the name label by label asking for an SOA record, then use whichever apex answers as the zone. If that apex does not match the exact zone name registered in your DNS provider account, the provider-side zone lookup comes back empty and the client gives up with an error like this.

Why would the SOA walk and the provider's zone list disagree?

Usually because a subdomain is NS-delegated to a different zone or provider than its parent, so the walk finds an SOA at a level the automation did not expect, or an intermediate label answers with SERVFAIL or NXDOMAIN instead of a clean no-SOA response, which confuses the walk before it reaches the true apex.

How do I fix a zone lookup failure for a subdomain?

Make the DNS-side authority and the provider account zone list agree. If the subdomain is meant to be its own zone, create it in the provider account and delegate to it with NS records at the parent. If delegation was accidental, remove those NS records so the walk falls through to the parent zone. Where the client supports it, you can also pin the zone explicitly instead of relying on autodetection.

Related field notes

Citations

On the problem:

  1. go-acme/lego docs: the dns01 package and its FindZoneByFqdn label walk. pkg.go.dev/github.com/go-acme/lego/v4/challenge/dns01
  2. cert-manager issue: waiting for dns-01 challenge propagation, could not determine the zone for a subdomain. github.com/cert-manager/cert-manager/issues/3093
  3. cert-manager issue: DNS-01 challenge gets the wrong zone info for Cloudflare and fails. github.com/jetstack/cert-manager/issues/4134

On the solution:

  1. cert-manager docs: DNS validation and how zone autodetection works. cert-manager.io/docs/tutorials/acme/dns-validation
  2. Cloudflare API docs: list zones by exact name. developers.cloudflare.com/api/resources/zones/methods/list
  3. cert-manager docs: troubleshooting ACME and Let's Encrypt certificate problems. cert-manager.io/docs/troubleshooting/acme

Stuck on a tricky one?

If you have a DNS, delegation, or certificate automation 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 lookup failure?

If this saved you a stalled certificate renewal or a confusing outage, 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