Reconciler Core Records

Hosting provider DNS check fails on a custom domain setup

You point your domain at GitHub Pages, Netlify, or Vercel, save the custom domain in the settings, and click check. It comes back "DNS check unsuccessful." No certificate, no traffic, just a red warning. Here is why the check fails, what records it actually wants to see, and how to set them so it turns green.

dig and Cloudflare API Python and Node.js Fixable through the DNS API
A circular object with a light in it
Photo by Growtika on Unsplash
The short answer

GitHub Pages and similar hosts verify a custom domain by checking the exact apex A/AAAA records and the exact www CNAME target the host publishes. The check almost always fails because old records from a previous host are still in the zone, only some of the four required apex IPs are present, or www points at the wrong target. A CNAME cannot share a name with any other record, so a stray A or TXT left on www will also break it. The fix is to replace the apex A records with the host's exact four IPs and point www at a single clean CNAME. Full records, commands, and a checker script are below.

The problem in plain words

When you add a custom domain to GitHub Pages, Netlify, or Vercel, the host does not just take your word for it. It looks up your domain's DNS records from the outside, the same way any visitor's browser would, and compares what it finds against the exact values it expects. If the apex domain (the bare example.com, with no subdomain) does not resolve to the host's published IP addresses, or the www subdomain does not point at the host's given hostname, the check fails and stays red.

This almost always comes down to leftover DNS. A domain that used to live on Squarespace, Wix, or a different host often still has that old provider's A record sitting at the apex, or an old CNAME sitting on www, and nobody removed it when the site moved. The new host's check has nothing to disagree with in principle, it is just looking at records that were never updated.

Host checks DNS apex A + www CNAME Zone still has old host's records records do not match DNS check unsuccessful No HTTPS cert issued
The host compares your apex and www records against its published values. Leftover or partial records from an old setup make the comparison fail.

Why it happens

The key insight

The host is not guessing. It publishes an exact, public list of the IPs and hostnames it expects, and it checks against that list every time you click check again. Fixing this is not about "adding some DNS," it is about making the zone match that exact published list, and nothing else, on the apex and on www.

The fix, as a flow

We check what the apex and www currently resolve to, compare that against the host's published required set, then replace the wrong or missing records in the zone with the exact expected values. We remove anything left over from an old host on those same names, since old records left in place are what breaks the check in most cases.

dig apex + www read current records Compare to host's required IPs + hostname Remove old or conflicting records Records match the host now? yes no, add correct records Check again in host settings
Compare what the zone currently serves against the host's exact published values, clean up anything old or conflicting, then add the correct records and re-check.

How to fix it

1

Check what the apex currently resolves to

Look up the A records for the bare domain. GitHub Pages requires exactly these four IPs: 185.199.108.153, 185.199.109.153, 185.199.110.153, and 185.199.111.153. A bad result looks like an old host's IP, only one or two of the four, or extra unrelated IPs mixed in with them.

Terminal
check-apex (shell)
dig +short A example.com

# what a good answer looks like, in any order:
# 185.199.108.153
# 185.199.109.153
# 185.199.110.153
# 185.199.111.153
2

Check what www currently points at

Look up the CNAME for www. It should return your GitHub Pages hostname with a trailing dot, such as yourusername.github.io., or yourorg.github.io. for an organization site. An empty result means an A record is sitting there instead of a CNAME, and a different target means www points somewhere else entirely.

Terminal
check-www (shell)
dig +short CNAME www.example.com

# a good answer looks like:
# yourusername.github.io.
3

Look for a conflicting record hiding next to the CNAME

A CNAME record cannot coexist with any other record on the same name. If www has a leftover A, TXT, or MX record from an old host sitting alongside the CNAME, that setup is invalid per RFC 1034 section 3.6.2, and the host's check will fail or behave inconsistently even though the CNAME itself looks correct.

Terminal
check-conflicts (shell)
dig www.example.com ANY
dig +short A www.example.com
dig +short TXT www.example.com

# from an authoritative angle, to rule out a slow-propagating edit:
dig @ns1.example.com example.com A
4

Replace the apex records with exactly the host's four IPs

At the DNS provider, delete any existing A records on the apex (@) and add all four of the GitHub Pages IPs. Add the matching AAAA records too if you want IPv6 reachability. Do not add just one or two, the host looks for the full set.

DNS record
zone record
example.com.  300  IN  A      185.199.108.153
example.com.  300  IN  A      185.199.109.153
example.com.  300  IN  A      185.199.110.153
example.com.  300  IN  A      185.199.111.153

; optional, for IPv6:
example.com.  300  IN  AAAA   2606:50c0:8000::153
example.com.  300  IN  AAAA   2606:50c0:8001::153
example.com.  300  IN  AAAA   2606:50c0:8002::153
example.com.  300  IN  AAAA   2606:50c0:8003::153
5

Delete any conflicting record on www, then add a single clean CNAME

Remove any A, TXT, or MX record sitting on www, and remove any old CNAME left over from a previous host such as Squarespace or Wix. Then add one CNAME pointing at your full GitHub Pages hostname, with the trailing dot.

DNS record
zone record
www.example.com.  300  IN  CNAME  yourusername.github.io.

; for an organization site, use the org account instead:
; www.example.com.  300  IN  CNAME  yourorg.github.io.
6

Set the custom domain in the repo and click check again

In the GitHub repo, go to Settings, Pages, and set Custom domain to the domain you are using. This commits a CNAME file to the repository. Once the records above are live, click Check again to re-trigger GitHub's verification.

Terminal
repo-file (shell)
echo "example.com" > CNAME
git add CNAME
git commit -m "Set custom domain for GitHub Pages"
git push

How to check it worked

Recheck both names, this time against public resolvers, not just your provider's own nameservers. Then confirm the host agrees and, once time has passed, confirm HTTPS actually works.

Terminal
verify (shell)
dig +short A example.com
dig +short CNAME www.example.com
dig @8.8.8.8 example.com A
dig @1.1.1.1 example.com A

# after up to 24 hours, confirm the certificate:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -dates

# a good answer looks like exactly these four, no extras:
# 185.199.108.153
# 185.199.109.153
# 185.199.110.153
# 185.199.111.153

In the GitHub repo settings under Pages, the custom domain status should change from "DNS check unsuccessful" to a green "DNS check successful."

The full code

Here is a small script that resolves the apex A records and the www CNAME target, compares them against the host's published required set, and reports exactly which apex IPs are missing or extra and whether www points at the right place. When repair is turned on, it replaces the stale records in Cloudflare with the correct ones. It stays in dry run by default so it reports before it writes.

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

custom_domain_dns_check.py
"""Detect a GitHub Pages custom domain DNS check failure and repair it via Cloudflare.
Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import logging

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

GITHUB_PAGES_A_RECORDS = {
    "185.199.108.153",
    "185.199.109.153",
    "185.199.110.153",
    "185.199.111.153",
}


def diagnose_pages_dns(apex_a_records: set, www_cname_target, required_a_records: set, expected_cname_suffix: str) -> dict:
    """Pure decision function. No network, no I/O."""
    missing = required_a_records - apex_a_records
    extra = apex_a_records - required_a_records
    apex_ok = not missing and not extra
    www_ok = bool(www_cname_target) and www_cname_target.rstrip(".").endswith(expected_cname_suffix.lstrip("."))
    return {
        "apex_ok": apex_ok,
        "apex_missing": missing,
        "apex_extra": extra,
        "www_ok": www_ok,
        "www_target": www_cname_target,
    }


def run():
    # Imported lazily so the pure function above can be tested with no
    # network libraries installed at all.
    import dns.resolver
    import requests

    domain = os.environ["DNS_DOMAIN"]
    github_hostname = os.environ.get("GITHUB_PAGES_HOSTNAME", "yourusername.github.io")
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]

    try:
        apex_answer = dns.resolver.resolve(domain, "A")
        apex_a_records = {str(r) for r in apex_answer}
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        apex_a_records = set()

    www_name = f"www.{domain}"
    try:
        cname_answer = dns.resolver.resolve(www_name, "CNAME")
        www_cname_target = str(cname_answer[0].target).rstrip(".")
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        www_cname_target = None

    report = diagnose_pages_dns(apex_a_records, www_cname_target, GITHUB_PAGES_A_RECORDS, ".github.io")
    log.info("Apex ok=%s missing=%s extra=%s", report["apex_ok"], report["apex_missing"], report["apex_extra"])
    log.info("www ok=%s target=%s", report["www_ok"], report["www_target"])

    if report["apex_ok"] and report["www_ok"]:
        log.info("Nothing to repair. DNS matches GitHub Pages requirements.")
        return

    headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
    base = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"

    if not report["apex_ok"]:
        log.info("Apex %s is wrong. %s replace the A records with the four GitHub Pages IPs.",
                  domain, "Would" if dry_run else "Will")
        if not dry_run:
            existing = requests.get(base, headers=headers, params={"type": "A", "name": domain}, timeout=30).json()
            for record in existing.get("result", []):
                requests.delete(f"{base}/{record['id']}", headers=headers, timeout=30).raise_for_status()
            for ip in sorted(GITHUB_PAGES_A_RECORDS):
                requests.post(base, headers=headers, json={"type": "A", "name": domain, "content": ip, "ttl": 300}, timeout=30).raise_for_status()

    if not report["www_ok"]:
        log.info("www.%s is wrong. %s replace it with a CNAME to %s.",
                  domain, "Would" if dry_run else "Will", github_hostname)
        if not dry_run:
            existing = requests.get(base, headers=headers, params={"name": www_name}, timeout=30).json()
            for record in existing.get("result", []):
                requests.delete(f"{base}/{record['id']}", headers=headers, timeout=30).raise_for_status()
            requests.post(base, headers=headers, json={"type": "CNAME", "name": www_name, "content": github_hostname, "ttl": 300}, timeout=30).raise_for_status()

    log.info("Done.")


if __name__ == "__main__":
    run()
custom-domain-dns-check.js
/**
 * Detect a GitHub Pages custom domain DNS check failure and repair it via Cloudflare.
 * Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

export const GITHUB_PAGES_A_RECORDS = new Set([
  "185.199.108.153",
  "185.199.109.153",
  "185.199.110.153",
  "185.199.111.153",
]);

export function diagnosePagesDns(apexARecords, wwwCnameTarget, requiredARecords, expectedCnameSuffix) {
  // Pure decision function. No network, no I/O.
  const missing = new Set([...requiredARecords].filter((ip) => !apexARecords.has(ip)));
  const extra = new Set([...apexARecords].filter((ip) => !requiredARecords.has(ip)));
  const apexOk = missing.size === 0 && extra.size === 0;
  const suffix = expectedCnameSuffix.replace(/^\./, "");
  const wwwOk = Boolean(wwwCnameTarget) && wwwCnameTarget.replace(/\.$/, "").endsWith(suffix);
  return {
    apex_ok: apexOk,
    apex_missing: missing,
    apex_extra: extra,
    www_ok: wwwOk,
    www_target: wwwCnameTarget,
  };
}

export async function run() {
  // Imported lazily so the pure function above can be tested with no
  // network modules touched at all.
  const dns = await import("node:dns");
  const resolvePromises = dns.promises;

  const domain = process.env.DNS_DOMAIN;
  const githubHostname = process.env.GITHUB_PAGES_HOSTNAME || "yourusername.github.io";
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  let apexARecords = new Set();
  try {
    apexARecords = new Set(await resolvePromises.resolve4(domain));
  } catch (err) {
    if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") throw err;
  }

  const wwwName = `www.${domain}`;
  let wwwCnameTarget = null;
  try {
    const cnames = await resolvePromises.resolveCname(wwwName);
    wwwCnameTarget = cnames[0] || null;
  } catch (err) {
    if (err.code !== "ENOTFOUND" && err.code !== "ENODATA") throw err;
  }

  const report = diagnosePagesDns(apexARecords, wwwCnameTarget, GITHUB_PAGES_A_RECORDS, ".github.io");
  console.log(`Apex ok=${report.apex_ok} missing=${[...report.apex_missing]} extra=${[...report.apex_extra]}`);
  console.log(`www ok=${report.www_ok} target=${report.www_target}`);

  if (report.apex_ok && report.www_ok) {
    console.log("Nothing to repair. DNS matches GitHub Pages requirements.");
    return;
  }

  const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
  const base = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;

  if (!report.apex_ok) {
    console.log(`Apex ${domain} is wrong. ${dryRun ? "Would" : "Will"} replace the A records with the four GitHub Pages IPs.`);
    if (!dryRun) {
      const existing = await (await fetch(`${base}?type=A&name=${domain}`, { headers })).json();
      for (const record of existing.result || []) {
        await fetch(`${base}/${record.id}`, { method: "DELETE", headers });
      }
      for (const ip of [...GITHUB_PAGES_A_RECORDS].sort()) {
        await fetch(base, { method: "POST", headers, body: JSON.stringify({ type: "A", name: domain, content: ip, ttl: 300 }) });
      }
    }
  }

  if (!report.www_ok) {
    console.log(`www.${domain} is wrong. ${dryRun ? "Would" : "Will"} replace it with a CNAME to ${githubHostname}.`);
    if (!dryRun) {
      const existing = await (await fetch(`${base}?name=${wwwName}`, { headers })).json();
      for (const record of existing.result || []) {
        await fetch(`${base}/${record.id}`, { method: "DELETE", headers });
      }
      await fetch(base, { method: "POST", headers, body: JSON.stringify({ type: "CNAME", name: wwwName, content: githubHostname, ttl: 300 }) });
    }
  }

  console.log("Done.");
}

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

Add a test

The comparison logic is the part worth testing on its own, because it decides which records the script thinks are wrong. It takes plain sets and strings, so the test needs no network and no DNS library at all.

test_custom_domain.py
from custom_domain_dns_check import diagnose_pages_dns, GITHUB_PAGES_A_RECORDS


def test_all_ok_when_records_match():
    report = diagnose_pages_dns(GITHUB_PAGES_A_RECORDS, "yourusername.github.io", GITHUB_PAGES_A_RECORDS, ".github.io")
    assert report["apex_ok"] is True
    assert report["www_ok"] is True


def test_apex_missing_ips_reported():
    partial = {"185.199.108.153", "185.199.109.153"}
    report = diagnose_pages_dns(partial, "yourusername.github.io", GITHUB_PAGES_A_RECORDS, ".github.io")
    assert report["apex_ok"] is False
    assert report["apex_missing"] == {"185.199.110.153", "185.199.111.153"}


def test_apex_extra_ip_reported():
    extra_set = GITHUB_PAGES_A_RECORDS | {"203.0.113.10"}
    report = diagnose_pages_dns(extra_set, "yourusername.github.io", GITHUB_PAGES_A_RECORDS, ".github.io")
    assert report["apex_ok"] is False
    assert report["apex_extra"] == {"203.0.113.10"}


def test_www_wrong_target_reported():
    report = diagnose_pages_dns(GITHUB_PAGES_A_RECORDS, "old-host.example.net", GITHUB_PAGES_A_RECORDS, ".github.io")
    assert report["www_ok"] is False


def test_www_missing_reported():
    report = diagnose_pages_dns(GITHUB_PAGES_A_RECORDS, None, GITHUB_PAGES_A_RECORDS, ".github.io")
    assert report["www_ok"] is False
custom-domain-dns-check.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnosePagesDns, GITHUB_PAGES_A_RECORDS } from "./custom-domain-dns-check.js";

test("all ok when records match", () => {
  const report = diagnosePagesDns(GITHUB_PAGES_A_RECORDS, "yourusername.github.io", GITHUB_PAGES_A_RECORDS, ".github.io");
  assert.equal(report.apex_ok, true);
  assert.equal(report.www_ok, true);
});

test("apex missing ips reported", () => {
  const partial = new Set(["185.199.108.153", "185.199.109.153"]);
  const report = diagnosePagesDns(partial, "yourusername.github.io", GITHUB_PAGES_A_RECORDS, ".github.io");
  assert.equal(report.apex_ok, false);
  assert.deepEqual([...report.apex_missing].sort(), ["185.199.110.153", "185.199.111.153"]);
});

test("apex extra ip reported", () => {
  const extraSet = new Set([...GITHUB_PAGES_A_RECORDS, "203.0.113.10"]);
  const report = diagnosePagesDns(extraSet, "yourusername.github.io", GITHUB_PAGES_A_RECORDS, ".github.io");
  assert.equal(report.apex_ok, false);
  assert.deepEqual([...report.apex_extra], ["203.0.113.10"]);
});

test("www wrong target reported", () => {
  const report = diagnosePagesDns(GITHUB_PAGES_A_RECORDS, "old-host.example.net", GITHUB_PAGES_A_RECORDS, ".github.io");
  assert.equal(report.www_ok, false);
});

test("www missing reported", () => {
  const report = diagnosePagesDns(GITHUB_PAGES_A_RECORDS, null, GITHUB_PAGES_A_RECORDS, ".github.io");
  assert.equal(report.www_ok, false);
});

Case studies

Migrated from Squarespace

The domain that kept two hosts arguing over www

A small business moved its site from Squarespace to a GitHub Pages site but only updated the apex A records during the move. The www subdomain still carried Squarespace's old A record. GitHub Pages saw a CNAME was needed but found an A record instead, and the check stayed red for a week.

Deleting the leftover A record on www and adding the single CNAME to username.github.io turned the check green within minutes of the change reaching public resolvers.

Copy-paste mistake

The apex that only had two of the four IPs

A developer copied GitHub's setup guide into their DNS provider but pasted only the first two A records, then got interrupted. The site loaded fine for most visitors because some requests happened to land on a working IP, but the DNS check kept failing since it wanted the complete set of four.

Running a diagnostic script during the incident showed exactly which two IPs were missing, so the fix was one clean batch of additions instead of more guesswork.

What good looks like

Once the records match, dig +short A example.com returns exactly the four GitHub Pages IPs and nothing else, dig +short CNAME www.example.com returns your GitHub Pages hostname, and the repo's Pages settings show a green "DNS check successful." Within about 24 hours, a valid Let's Encrypt certificate is issued and HTTPS enforcement becomes available.

FAQ

Why does GitHub Pages say the DNS check is unsuccessful for my domain?

GitHub Pages checks that your apex domain has exactly the four GitHub A records and that www has a CNAME pointing at your username.github.io. The check fails when old records from a previous host are still there, only some of the four IPs are present, or www points at the wrong target or has a conflicting record on the same name.

Can a CNAME record and an A record exist on the same name at once?

No. A CNAME must be the only record on that exact name. If www has a CNAME plus a leftover A, TXT, or MX record from an old host, that is invalid per RFC 1034 and will make the DNS check fail or behave inconsistently.

How long does it take for the DNS check to pass after I fix the records?

Once the correct records are live and visible to public resolvers, clicking Check again in the host's settings usually confirms it within minutes. Full propagation to every resolver can take up to the old record's TTL, and the HTTPS certificate can take up to 24 hours to issue after the check passes.

Related field notes

Citations

On the problem:

  1. GitHub Docs: Troubleshooting custom domains and GitHub Pages. docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/troubleshooting-custom-domains-and-github-pages
  2. GitHub Docs: About custom domains and GitHub Pages. docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/about-custom-domains-and-github-pages
  3. RFC 1034: Domain Names, Concepts and Facilities, section 3.6.2 on CNAME restrictions. rfc-editor.org/rfc/rfc1034

On the solution:

  1. GitHub Docs: Managing a custom domain for your GitHub Pages site. docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site
  2. Cloudflare API: DNS records for a zone, list. developers.cloudflare.com/api/operations/dns-records-for-a-zone-list-dns-records

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 DNS check?

If this saved you a confusing afternoon of DNS debugging, 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