GUIDES

Moving GitHub Pages to a Custom Domain with Cloudflare DNS

Animated Unix Magick terminal intro showing GitHub Pages custom-domain checks for DNS, TLS, and HTTPS.
DNS, Pages, TLS, and redirects are separate layers. Check them separately.

I bought a short domain for this site and expected the move from github.io to take a few DNS records and maybe ten minutes.

The DNS part really was easy. The useful part was everything around it: a new-domain transfer lock, registrar parking records, GitHub’s account-level domain verification, a CNAME commit appearing on main, certificate state taking time to settle, and one stale Jekyll setting that the browser never exposed.

The final setup is simple:

Registrar:          Name.com
Authoritative DNS:  Cloudflare
Hosting:            GitHub Pages
Canonical site:     https://0y.lol/

The examples below use example.com and YOUR-USERNAME so they are safe to copy.


The registrar does not have to move

The first wrinkle was the domain itself.

It was newly registered, so moving it to another registrar was not available yet. That sounds like a blocker until you separate registration from DNS hosting.

They are different jobs.

Registrar
  owns the registration relationship

Authoritative DNS
  answers DNS for the domain

GitHub Pages
  serves the website

A new registration can be subject to a 60-day inter-registrar transfer restriction. That does not prevent changing the domain’s nameservers or changing where the site is hosted.

So I left the registration at Name.com and changed only the authoritative nameservers to the two Cloudflare assigned to the zone.

That let the rest of the migration continue immediately.


Clean the DNS zone before adding GitHub

Cloudflare imported the existing DNS when I added the domain.

That included records left over from the registrar’s parking setup, including a wildcard.

I removed them before adding anything for Pages.

For this site, the useful zone ended up looking like this:

Type   Name   Target
----   ----   --------------------------------
A      @      185.199.108.153
A      @      185.199.109.153
A      @      185.199.110.153
A      @      185.199.111.153
CNAME  www    YOUR-USERNAME.github.io
TXT    _github-pages-challenge-YOUR-USERNAME

The TXT value is generated by GitHub and is intentionally omitted here.

GitHub currently documents those four IPv4 addresses for an apex Pages domain. It also supports the documented IPv6 set, or ALIAS / ANAME where the DNS provider supports them. I used the four A records because they are simple and easy to verify.

The wildcard was removed on purpose.

*.example.com

GitHub strongly warns against wildcard DNS for Pages because it can create domain-takeover opportunities beyond the immediate subdomains protected by verification.

There was no reason for this site to keep one.


Verify the domain before attaching it to Pages

GitHub has an account-level domain verification feature under the account’s Pages settings.

For a personal account:

GitHub
└── Settings
    └── Pages
        └── Add a domain

I added the apex domain there first.

GitHub produced a TXT record with a name similar to:

_github-pages-challenge-YOUR-USERNAME.example.com

and a verification value.

That record went into Cloudflare and stayed there after verification.

Keep the TXT record after verification. GitHub recommends leaving it in DNS so the domain stays verified; once verified, other GitHub users cannot claim the domain or its immediate subdomains for their own Pages sites.

That is worth doing before the repository-level custom-domain setting.

Checking the TXT record without dig

On Gentoo, dig lives in net-dns/bind-tools. It was masked on this machine, and I was too lazy to stop and fix the mask just for this, so I used DNS-over-HTTPS instead:

USER='YOUR-USERNAME'
DOMAIN='example.com'

curl -fsS \
  "https://dns.google/resolve?name=_github-pages-challenge-${USER}.${DOMAIN}&type=TXT" |
jq -r '
  .Answer[]? |
  select(.type == 16) |
  .data
'

Once the expected TXT value appeared, I completed verification in GitHub.

I would not put the real verification value in screenshots or documentation. It is not the same class of secret as an API token, but publishing it buys nothing.


Add the Pages records

For the apex:

A @ 185.199.108.153
A @ 185.199.109.153
A @ 185.199.110.153
A @ 185.199.111.153

For www:

CNAME www YOUR-USERNAME.github.io

The www CNAME points to the GitHub Pages default hostname, not to the apex and not to a repository path.

GitHub recommends configuring www even when the apex is canonical. With both sides configured correctly, Pages handles the redirect between them.

Why I started with Cloudflare DNS-only

I left the web records gray-clouded while bringing the site up.

That was a testing decision, not a GitHub requirement and not Cloudflare’s general recommendation for web traffic.

With DNS-only records, the path was easy to reason about:

browser
  |
Cloudflare authoritative DNS
  |
GitHub Pages

No Cloudflare HTTP proxy was in the middle while GitHub checked the domain and provisioned TLS.

Cloudflare generally recommends proxying web-serving A, AAAA, and CNAME records because that enables its DDoS protection, cache, WAF, redirect rules, and HTTP analytics. I wanted a known-good direct GitHub Pages baseline first.

For a static Pages site, either choice can be reasonable. Just be clear about which layer is answering HTTP while debugging.


Prove DNS before changing anything else

The apex check:

DOMAIN='example.com'

curl -fsS \
  "https://dns.google/resolve?name=${DOMAIN}&type=A" |
jq -r '
  .Answer[]? |
  select(.type == 1) |
  .data
' |
sort -V

Expected:

185.199.108.153
185.199.109.153
185.199.110.153
185.199.111.153

Then www:

curl -fsS \
  "https://dns.google/resolve?name=www.${DOMAIN}&type=CNAME" |
jq -r '
  .Answer[]? |
  select(.type == 5) |
  .data
'

Expected:

YOUR-USERNAME.github.io.

The trailing dot is normal.

This is the point where I want DNS to be boring. If the name still resolves to a registrar parking address or some old host, fix that first instead of changing Pages or TLS settings at random.


Attach the domain with gh

The repository Pages setting can be changed in the web UI, but the REST API makes the state easy to reproduce.

OWNER='YOUR-USERNAME'
REPO='YOUR-USERNAME.github.io'
DOMAIN='example.com'

gh api \
  --method PUT \
  "repos/${OWNER}/${REPO}/pages" \
  -f cname="$DOMAIN"

GitHub’s Pages API supports both cname and https_enforced.

Because this site publishes from a branch, setting the custom domain also resulted in a root-level:

CNAME

containing:

example.com

That caused one of the more interesting problems in the whole migration.

I already had the next site change in a local canary branch. GitHub created the CNAME commit on remote main, so the graph became:

        local canary change
       /
previous release
       \
        Create CNAME

Nothing was broken. I had simply changed main through GitHub while a local branch was based on the previous commit.

The fix was to inspect the remote change, verify it contained only the new CNAME, and rebase the canary onto the new origin/main.

Before starting another feature after changing the custom domain, I would now always do:

git fetch --prune origin

git --no-pager log \
  --graph \
  --decorate \
  --oneline \
  --all \
  -8

GitHub’s own troubleshooting documentation makes the same practical point: if Pages adds a CNAME file to a branch-published site, pull that commit into local work so a later build or push does not lose it.


Watch Pages state, but do not trust one field blindly

The Pages API is useful:

OWNER='YOUR-USERNAME'
REPO='YOUR-USERNAME.github.io'

gh api \
  "repos/${OWNER}/${REPO}/pages" |
jq '{
  cname,
  https_enforced,
  status,
  source,
  protected_domain_state,
  https_certificate
}'

The important fields for this migration were:

cname
https_enforced
status
source

GitHub’s current API schema also exposes protected_domain_state and https_certificate.

In practice, I still treated direct network tests as authoritative.

During the migration, one Pages health/API view briefly returned incomplete-looking certificate or health data while the live site was already serving correctly. The DNS-health endpoint itself is asynchronous: the first request may return HTTP 202 and start the check, with later requests returning the result.

That means this is useful:

gh api \
  "repos/${OWNER}/${REPO}/pages/health" |
jq .

but it is not a substitute for checking DNS and HTTPS from the outside.


Let GitHub provision TLS

Once DNS was correct and the custom domain was attached, GitHub handled the certificate.

GitHub notes that HTTPS for a newly configured custom domain can take up to about an hour to become available.

This is where impatience can create more work than the original problem.

I checked the site directly:

curl -sSI \
  "https://${DOMAIN}/" |
sed -n '1,12p'

and kept the DNS configuration stable while Pages converged.

GitHub also warns that extra apex A, AAAA, ALIAS, or ANAME records, or incorrect CNAMEs for the Pages subdomain, can prevent the certificate from being generated.

That is another reason to remove the registrar’s parking records instead of leaving them beside the new records.


Enforce HTTPS

After the custom domain was serving HTTPS, I enabled enforcement through the API:

gh api \
  --method PUT \
  "repos/${OWNER}/${REPO}/pages" \
  -F https_enforced=true

Then checked it:

gh api \
  "repos/${OWNER}/${REPO}/pages" |
jq '{
  cname,
  https_enforced,
  status
}'

The expected state:

{
  "cname": "example.com",
  "https_enforced": true,
  "status": "built"
}

Test all four URLs

This was the check that made the migration feel finished.

Do not let curl follow redirects yet:

DOMAIN='example.com'

for url in \
  "http://${DOMAIN}/" \
  "https://${DOMAIN}/" \
  "http://www.${DOMAIN}/" \
  "https://www.${DOMAIN}/"
do
    printf '\n=== %s ===\n' "$url"

    curl -sSI \
      --max-time 20 \
      "$url" |
    sed -n '1,12p'
done

For an apex-canonical setup, I wanted:

http://example.com/
  301 -> https://example.com/

https://example.com/
  200

http://www.example.com/
  301 -> https://example.com/

https://www.example.com/
  301 -> https://example.com/

GitHub automatically handles the apex/www redirect when both DNS sides are configured and the apex is the selected custom domain.

During my migration, the redirects did not all settle at exactly the same moment. www was doing the right thing before the apex HTTP-to-HTTPS behavior looked final everywhere I checked.

I did not change the DNS again. The certificate was valid, Pages had the right domain, and the records were correct.

After the edge configuration converged, all four paths behaved as expected.


The thing I missed: Jekyll still knew the old URL

The site was working on the custom domain, but a later source audit found this in _config.yml:

url: "https://YOUR-USERNAME.github.io"
baseurl: ""

The browser did not expose the mistake because GitHub Pages was already answering at the new domain.

Jekyll’s url setting matters to anything that generates absolute URLs from site configuration: feeds, canonical metadata, plugins, templates, or future code that uses site.url.

For an apex custom domain it should be:

url: "https://example.com"
baseurl: ""

Check it:

grep -nE '^(url|baseurl):' _config.yml

Then rebuild:

bundle exec jekyll build

This is exactly the kind of detail that should be in a post-migration audit instead of trusted to memory.


Final validation

This is the condensed version of the checks I care about:

OWNER='YOUR-USERNAME'
REPO='YOUR-USERNAME.github.io'
DOMAIN='example.com'

echo '=== PAGES ==='

gh api \
  "repos/${OWNER}/${REPO}/pages" |
jq '{
  cname,
  https_enforced,
  status,
  source,
  protected_domain_state
}'

echo
echo '=== LATEST BUILD ==='

gh api \
  "repos/${OWNER}/${REPO}/pages/builds/latest" |
jq '{
  status,
  commit,
  created_at,
  updated_at,
  error
}'

echo
echo '=== REDIRECT MATRIX ==='

for url in \
  "http://${DOMAIN}/" \
  "https://${DOMAIN}/" \
  "http://www.${DOMAIN}/" \
  "https://www.${DOMAIN}/"
do
    printf '\n%s\n' "$url"

    curl -sSI \
      --max-time 20 \
      "$url" |
    awk '
      BEGIN { IGNORECASE = 1 }

      NR == 1 {
        sub(/\r$/, "")
        print
      }

      /^location:/ {
        sub(/\r$/, "")
        print
      }
    '
done

echo
echo '=== REPOSITORY CNAME ==='

cat CNAME

echo
echo '=== JEKYLL URL ==='

grep -nE \
  '^(url|baseurl):' \
  _config.yml

For this layout, the invariants are:

GitHub custom domain    = example.com
repository CNAME        = example.com
Jekyll url              = https://example.com
HTTPS enforcement       = true
HTTPS apex              = 200
HTTP apex               = 301 to HTTPS apex
www                     = 301 to HTTPS apex

If one of those disagrees with the others, I would fix that layer rather than papering over it somewhere else.


Cloudflare proxying later

The site is still a good candidate for Cloudflare proxying if I want Cloudflare’s HTTP-layer features.

With proxying enabled:

browser
  |
Cloudflare proxy
  |
GitHub Pages

That can add DDoS filtering, WAF rules, caching, redirects, Workers, and HTTP analytics.

For the initial migration I preferred the direct GitHub Pages path because it reduced the number of moving parts while TLS and redirects were being verified.

If I turn the orange cloud on later, I will treat it like another deployment:

baseline current behavior
enable proxy
retest TLS
retest apex/www redirects
retest RSS/feed
retest static assets
retest cache behavior

The important thing is not that DNS-only is universally better. It is that I know which system is doing what before adding another one.


What I would do the same next time

Verify the domain at the GitHub account level first.

Remove parking and wildcard records instead of working around them.

Use the documented GitHub Pages DNS targets and verify them independently.

Keep the GitHub verification TXT record.

Fetch the repository immediately after changing the Pages custom domain so the generated CNAME commit does not surprise the next branch.

Wait for TLS instead of changing correct DNS repeatedly.

Test the complete apex/www and HTTP/HTTPS matrix.

And check the static-site generator’s canonical URL after the site is live.

The actual DNS change is the easy part. The migration is done when every layer agrees on the same hostname.


References