This article was co-authored with generative AI. Facts have been checked against public documentation where feasible, but errors may remain. Please verify primary sources before relying on this for important decisions.
This article walks through moving the custom domain of a site published on GitHub Pages to a different domain, and redirecting the old URLs to the new ones (while preserving the path). It looks simple at first, but there are pitfalls such as the PWA Service Worker hijacking the redirect and GitHub not letting go of the old domain, giving you domain is already taken. I have organized this as a procedure that gets ahead of those problems by proceeding in the correct order, so you do not step on them after the fact.
The subject is a case of migrating a general-purpose static site with the following configuration to a different domain (for explanation, the domains are old.example.com → new.example.com).
- An SPA generated by a static site generator (including a PWA configuration)
- Published to the
gh-pagesbranch via GitHub Actions (e.g.,peaceiris/actions-gh-pages) - A custom domain set via a
CNAMEdirectly under the publish directory
So that it reads as generally applicable to any "moving a static site published on a custom domain (including a PWA) on GitHub Pages" situation, the rest is written independently of any specific site or implementation.
Overall design and "what to decide up front"
The fundamental principle of GitHub Pages is 1 repository = 1 custom domain (one CNAME file directly under the publish branch). Since we are dealing with two domains, we split into two repositories as well.
- The main site (serves the new URL) … Use the existing repository as-is, swapping only
CNAMEand absolute URLs - The redirect site (forwards old URL → new URL) … A new lightweight repository (only
index.html/404.html/sw.js/CNAME)
And there are two things you must decide before starting. Fixing these first makes the DNS request described later and the already taken handling go through in one shot.
- Which account/Organization will ultimately own each domain (repository) If you later transfer a repository to a different org, the custom domain becomes "owned by a different account" and the acquisition conditions get stricter (cross-account). Create the repository in the final owner from the start.
- Which
<owner>.github.iowill the old domain's DNS ultimately point to If the main and redirect sites have different owners, the old domain's CNAME points to the redirect owner's<owner>.github.io.
The big picture of the procedure
- Swap the main site's
CNAME, absolute URLs, and self-referencing URLs inside the data to the new domain (do not push yet) - Create the redirect repository in the final owner (bundle the path-preserving redirect + the PWA-countermeasure
sw.jsfrom the start) - Bundle the DNS request into one (new domain CNAME, old domain CNAME, verification TXT)
- Deploy the main site to the new domain (= release the old domain)
- Complete domain verification on the redirect side and acquire the old domain without waiting for
already taken - Verify behavior
Each step is detailed below.
Step 1: Leave the main site in place, point only references to the new domain
Use the main repository as-is, and swap the following.
static/CNAMEto the new domain- The
BASE_URLused for OGP, canonical, and sitemap to the new domain
Something else that is easy to overlook is the self-referencing absolute URLs inside the data. For example, IIIF manifests / annotation lists and the like sometimes have the old domain hard-coded as @id or as an image service URL, and depending on scale this can reach thousands of files. Replace them in bulk.
# Reliably NUL-separate with find -print0 and substitute with perl
find static/data content -type f \( -name '*.json' -o -name '*.md' \) -print0 \
| xargs -0 perl -pi -e 's/old\.example\.com/new.example.com/g'
# Always verify by count before saying "done" (it should be 0)
grep -rIl 'old\.example\.com' static/data content | wc -l
At this point, do not push yet (so that the timing of releasing the old domain is controlled in Step 4).
Step 2: Create the redirect repository in the final owner
Create the repository in the final owner from the start (in this article, on the Organization side). Avoid a later transfer here.
Path-preserving redirect
GitHub Pages cannot do server-side 301 redirects (per-content) (it does internally return 301 for HTTP→HTTPS or apex↔www normalization, but it cannot forward arbitrary paths). So we forward with client-side JS while preserving the path, query, and hash. The key is to put the same processing in both index.html (the top) and 404.html (deep paths). GitHub Pages returns 404.html for a nonexistent path, so this catches paths of any depth. In this case the HTTP status returned is 404, not 200, but browsers execute the body's <script> even on a 404, so the JS redirect works without issue.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<link rel="canonical" href="https://new.example.com/">
<script>
(function () {
// PWA countermeasure (best-effort. The definitive disabling is handled by sw.js below)
try {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then(function (rs) {
rs.forEach(function (r) { r.unregister(); });
});
}
if (window.caches && caches.keys) {
caches.keys().then(function (ks) { ks.forEach(function (k) { caches.delete(k); }); });
}
} catch (e) {}
var target = "https://new.example.com"
+ window.location.pathname
+ window.location.search
+ window.location.hash;
window.location.replace(target);
})();
</script>
<noscript>
<meta http-equiv="refresh" content="0; url=https://new.example.com/">
</noscript>
</head>
<body>
<p>This site has moved to <a href="https://new.example.com/">https://new.example.com/</a>.</p>
</body>
</html>
If it was a PWA, add a "self-destructing Service Worker" from the start
If the main site was a PWA (a configuration that registers a Service Worker), it is important to build this in from the start.
The old domain's origin still has previously registered Service Workers remaining in the browser. If left alone, even after migration that residual SW may serve an old cache and hijack the redirect HTML (opening the old domain looks blank or 404). Because an SW controls the origin, it keeps returning the cache for navigation.
The countermeasure is to place a self-destructing /sw.js in the redirect repository. Because the browser checks for updates to /sw.js on navigation, the old SW is replaced by this file, which unregisters itself → deletes all caches → reloads the tab.
// sw.js —— a self-destructing Service Worker
self.addEventListener('install', function () {
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
event.waitUntil((async function () {
try {
var keys = await caches.keys();
await Promise.all(keys.map(function (k) { return caches.delete(k); }));
} catch (e) {}
await self.registration.unregister();
var clients = await self.clients.matchAll({ type: 'window' });
clients.forEach(function (c) { c.navigate(c.url); });
})());
});
For the CNAME file, write the old domain, and in Settings → Pages just choose "Deploy from a branch / main / root." No build is needed.
Step 3: Request DNS all at once, in a single go
When requesting the person who manages DNS, do not ask piecemeal many times; bundle the necessary records into one request. Because you fixed the ownership arrangement in Step 0, you can specify the final form in one shot.
- New domain:
new.example.com→<main owner>.github.io(CNAME) - Old domain:
old.example.com→<redirect owner>.github.io(CNAME) - Verification TXT (used in Step 5; putting it in early saves you from waiting on
already taken):- Name:
_github-pages-challenge-<owner>.old.example.com - Value: the token shown in GitHub's org/personal Pages settings under "Add a domain"
- Name:
Because the TXT value can only be obtained from GitHub's admin screen, open just the "Add a domain" screen in Step 5 first, note the token, and request it together with the CNAMEs to reduce round trips.
# Confirm propagation
dig +short CNAME new.example.com
dig +short CNAME old.example.com
dig +short TXT _github-pages-challenge-<owner>.old.example.com
Step 4: Deploy the main site to the new domain (release the old domain)
Push the changes from Step 1 and deploy. When the main site's publish branch's CNAME changes to the new domain, GitHub Pages' custom domain switches to the new domain, and the old domain is released from the main repository.
gh run watch <run-id> --exit-status
gh api repos/<owner>/<body-repo>/pages -q '{cname:.cname, status:.status}'
curl -sS -I https://new.example.com/ # confirm 200
Step 5: Acquire without waiting on already taken, via domain verification
This is the biggest point. When you try to set the old domain as the custom domain of the redirect repository, you may get the following error.
The custom domain `old.example.com` is already taken.
If you are the owner of this domain, check out ... verifying-your-custom-domain-for-github-pages ...
This happens because, although the main repository's active custom domain is already the new domain, GitHub keeps the old domain tied to the main repository (a reservation to prevent takeover). The condition for this reservation to release automatically is not stated in the official docs, and in my experience, it may not release automatically even if you switch DNS or wait (this time it did not release even after waiting a full day).
On the other hand, GitHub's official documentation clearly states that "if you verify a domain you own, a custom domain held by another user/organization's Pages is released immediately as well." This is the very procedure the error message links to, and the reliable solution is domain verification. If you put the TXT in during Step 3, all that is left is to finalize the verification.
(Note that if the old domain is already "verified" by a different account, release via verification is not possible. If, as in this article, you fix the ownership arrangement first and consolidate under the same owner, you will not hit this exception.)
- Go to the Organization's (or personal) Settings → Pages → Verified and approved domains → Add a domain
- Enter the old domain (with the TXT of the token you noted in Step 3 already in DNS)
- Click Verify → verification complete
- Set the old domain as the custom domain of the redirect repository
gh api -X PUT repos/<owner>/<redirect-repo>/pages -f "cname=old.example.com"
gh api repos/<owner>/<redirect-repo>/pages -q '{cname:.cname, status:.status}'
If verification has passed, the already taken lock is overridden and you can acquire it without waiting.
Step 6: Verify behavior
# Whether the forwarding target is in place (location.replace to the new domain)
curl -sS https://old.example.com/ | grep -o 'new.example.com'
# Whether the self-destructing SW is being served (application/javascript containing unregister)
curl -sS -o /dev/null -w "%{http_code} %{content_type}\n" https://old.example.com/sw.js
# HTTPS / http→https 301 / certificate
curl -sS -I http://old.example.com/ # → 301 to https
gh api repos/<owner>/<redirect-repo>/pages \
-q '{cname:.cname, https:.https_enforced, cert:.https_certificate.state}'
You are done if the certificate is approved, https_enforced: true, and http→https is 301. Deep paths on the old URL are also forwarded to the new URL with the path preserved.
Summary
- Fix the ownership arrangement before starting (which account/org holds the old and new domains). Avoid a later transfer, since it creates cross-account problems
- Leave the main site in place and swap the CNAME + absolute URLs + self-referencing URLs inside the data. Verify the replacement by count
- The redirect is a separate lightweight repository. Put path-preserving JS in both
index.htmland404.html(server-side 301 is not possible) - If it is a PWA, bundle a self-destructing
sw.jsfrom the start to prevent takeover by a residual Service Worker - Bundle the DNS request into one (new CNAME, old CNAME, verification TXT)
already takenis caused by a reservation inside GitHub and may not release even if you wait. Solve it reliably with domain verification (TXT + Verify). Putting the TXT in is not enough until you press the Verify button
"Fix the ownership arrangement first → request DNS all at once → solve already taken with verification rather than waiting." Proceed in this order and you can complete a GitHub Pages custom-domain migration without rework.

Comments
…