本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でもご確認ください。

GitHub Pages で公開しているサイトのカスタムドメインを別ドメインに移し、旧URLからは新URLへ(パスを保持したまま)リダイレクトさせる手順をまとめます。一見単純ですが、PWA の Service Worker がリダイレクトを乗っ取るGitHub が旧ドメインを離さず domain is already taken になるといった落とし穴があります。これらを後追いで踏まないよう、正しい順序で先回りして進める手順として整理しました。

題材は、次のような一般的な構成の静的サイトを、別ドメインへ移行するケースです(ドメインは説明用に old.example.comnew.example.com とします)。

  • 静的サイトジェネレータで生成した SPA(PWA 構成を含む)
  • GitHub Actions(例: peaceiris/actions-gh-pages)で gh-pages ブランチに publish
  • 公開ディレクトリ直下の CNAME でカスタムドメインを設定

「独自ドメインで公開している静的サイト(PWA を含む)を GitHub Pages 上で引っ越す」場面全般に当てはまる話として読めるよう、以降は具体的なサイトや実装に依存しない形で書きます。

全体設計と「先に決めておくこと」

GitHub Pages の大原則は 1リポジトリ = カスタムドメイン1つ(公開ブランチ直下の CNAME ファイル1つ)です。2つのドメインを扱うのでリポジトリも2つに分けます。

  • 本体サイト(新URL を配信)… 既存リポジトリをそのまま使い、CNAME と絶対URLだけ差し替える
  • リダイレクト用サイト(旧URL → 新URL 転送)… 新規の軽量リポジトリ(index.html/404.html/sw.js/CNAME だけ)

そして着手前に必ず決めておくべきことが2つあります。これを最初に確定させておくと、後述の DNS 依頼や already taken 対応が一発で通ります。

  1. どのアカウント/Organization が各ドメイン(リポジトリ)を最終的に所有するか 後からリポジトリを別 org へ transfer すると、カスタムドメインが「別アカウント所有」になり取得条件が厳しくなります(cross-account)。最初から最終所有先にリポジトリを作ること。
  2. 旧ドメインの DNS を最終的にどの <owner>.github.io に向けるか 本体とリダイレクトの所有者が違うなら、旧ドメインの CNAME はリダイレクト所有者の <owner>.github.io を向けます。

:::message この2点を曖昧なまま進めると、DNS 変更を小刻みに依頼する羽目になったり、already taken の原因切り分けが「旧リポジトリの予約か、アカウント跨ぎか」で混ざって難しくなります。所有形態を先に固定するのが最大のコツです。 :::

手順の全体像

  1. 本体の CNAME・絶対URL・データ内の自己参照URLを新ドメインへ差し替える(まだ push しない)
  2. リダイレクト用リポジトリを最終所有先に作る(パス保持リダイレクト+PWA対策の sw.js を最初から同梱)
  3. DNS 依頼を1回にまとめる(新ドメイン CNAME・旧ドメイン CNAME・検証 TXT)
  4. 本体を新ドメインにデプロイ(=旧ドメインを解放)
  5. リダイレクト側でドメイン検証を済ませ、already taken を待たずに旧ドメインを取得
  6. 動作確認

以下、各ステップを詳述します。

Step 1: 本体は据え置き、参照だけ新ドメインへ

本体リポジトリはそのまま使い、次を差し替えます。

  • static/CNAME を新ドメインに
  • OGP・canonical・sitemap 用の BASE_URL を新ドメインに

加えて見落としがちなのが、データ内の自己参照絶対URLです。たとえば IIIF の manifest / annotation list などに @id や画像サービスURLとして旧ドメインが直書きされていることがあり、規模によっては数千ファイルに及びます。一括置換します。

# find -print0 で確実に NUL 区切りにして 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'

# 「完了」と言う前に必ず件数で検算する(0 になるはず)
grep -rIl 'old\.example\.com' static/data content | wc -l

:::message grep -rlZ ... | xargs -0 でも同様のことができますが、環境によっては -Z の NUL 出力が期待どおりにならず黙って全件置換漏れすることがありました。find -print0 からのパイプの方が確実で、日本語やカッコを含むパス名でも安全です。置換後は必ず残数を数えて確認します。 :::

この時点ではまだ push しません(旧ドメインの解放タイミングを Step 4 で制御するため)。

Step 2: リダイレクト用リポジトリを最終所有先に作る

最初から最終所有先(本記事では Organization 側)にリポジトリを作成します。ここでの後からの transfer は避けます。

パス保持リダイレクト

GitHub Pages は(コンテンツ単位の)サーバ側 301 リダイレクトができません(HTTP→HTTPS や apex↔www の正規化では内部的に 301 を返しますが、任意パスの転送はできません)。そこでクライアント側 JS で、パス・クエリ・ハッシュを保持したまま転送します。ポイントは index.html(トップ)と 404.html(深いパス)の両方に同じ処理を置くこと。GitHub Pages は存在しないパスに 404.html を返すため、これで任意の深さのパスを拾えます。このとき HTTP ステータスは 200 ではなく 404 が返りますが、ブラウザは 404 でも本文の <script> を実行するため、JS リダイレクトは問題なく動作します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <link rel="canonical" href="https://new.example.com/">
  <script>
    (function () {
      // PWA 対策(best-effort。確実な無効化は後述の sw.js が担う)
      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>このサイトは <a href="https://new.example.com/">https://new.example.com/</a> に移転しました。</p>
</body>
</html>

:::message

  • canonicalnoindex を併用しない: 転送ページには新URLへの canonical だけを置いています。noindex を併記すると「索引から外せ」と「新URLへ正規化せよ」という矛盾したシグナルになり、Google は併用を非推奨としています。移転のシグナルを新URLへ渡したいので canonical を優先します。
  • 上記インラインの SW/キャッシュ削除は best-effortです。location.replace の直後に発火するため多くの場合ナビゲーション前には完了せず、また旧 SW がナビゲーションを乗っ取っている場合はこの HTML 自体が表示されず走りません。確実な無効化は次節の自己消滅 sw.js が担います(このインライン処理は無害な補助にすぎません)。
  • <noscript> のフォールバックはパスを保持せずルート / に飛びます(JS 無効という稀ケースの保険)。 :::

PWA だったら「自己消滅する Service Worker」を最初から入れる

本体サイトが PWA(Service Worker を登録する構成)だった場合、これを最初から仕込んでおくのが重要です。

旧ドメインのオリジンには、過去に登録された Service Worker がブラウザに残っています。放置すると、移転後もその残存 SW が古いキャッシュを配信し、リダイレクト用 HTML を乗っ取ってしまう(旧ドメインを開くと blank や 404 に見える)ことがあります。SW はオリジンを制御するので、ナビゲーションにキャッシュを返し続けるためです。

対策は、リダイレクト用リポジトリに自己消滅する /sw.js を置くこと。ブラウザはナビゲーション時に /sw.js の更新を確認するので、旧 SW はこのファイルに置き換わり、自身を unregister → 全キャッシュ削除 → タブ再読込します。

// sw.js —— 自己消滅する 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); });
  })());
});

:::message alert 自己消滅 sw.js が旧 SW を「更新」として置き換えられるのは、旧登録の script URL とスコープが完全一致する場合だけです。旧 PWA が /sw.js(スコープ /)で登録していれば成立します(多くの PWA プラグインの既定はこの配置です)。別パス(例 /_nuxt/sw.js)やカスタムスコープで登録していた場合は、/sw.js を置いても旧登録は置き換わらず黙って無効化に失敗します。DevTools → Application → Service Workers(または chrome://serviceworker-internals)で旧 SW の実際の script URL / scope を確認し、同一パス・同一ファイル名で置いてください。 :::

CNAME ファイルには旧ドメインを書き、Settings → Pages で「Deploy from a branch / main / root」を選ぶだけ。ビルドは不要です。

Step 3: DNS はまとめて1回で依頼する

DNS を管理する担当者に依頼する場合、小刻みに何度も頼まず、必要なレコードを一度にまとめて依頼します。所有形態を Step 0 で確定させてあるので、最終形を一発で指定できます。

  • 新ドメイン: new.example.com<本体owner>.github.io(CNAME)
  • 旧ドメイン: old.example.com<リダイレクトowner>.github.io(CNAME)
  • 検証用 TXT(Step 5 で使用。先に入れておくと already taken を待たずに済む):
    • 名前: _github-pages-challenge-<owner>.old.example.com
    • 値: GitHub の org/個人 Pages 設定「Add a domain」で表示されるトークン

TXT の値は GitHub の管理画面でしか取得できないため、先に Step 5 の「Add a domain」画面だけ開いてトークンを控え、CNAME と一緒に依頼すると往復が減ります。

# 反映確認
dig +short CNAME new.example.com
dig +short CNAME old.example.com
dig +short TXT _github-pages-challenge-<owner>.old.example.com

Step 4: 本体を新ドメインにデプロイ(旧ドメインを解放)

Step 1 の変更を push してデプロイします。本体の公開ブランチの CNAME が新ドメインに変わることで、GitHub Pages のカスタムドメインが新ドメインへ切り替わり、旧ドメインが本体リポジトリから解放されます。

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/   # 200 を確認

Step 5: ドメイン検証で already taken を待たずに取得

ここが最大のポイントです。旧ドメインをリダイレクト用リポジトリのカスタムドメインに設定しようとすると、次のエラーが出ることがあります。

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 ...

これは、本体リポジトリのアクティブなカスタムドメインは既に新ドメインになっているのに、**GitHub が旧ドメインを本体リポジトリに紐づけて保持したまま(乗っ取り防止のための予約)**になっているために起きます。この予約の自動解放の条件は公式には明記されておらず、経験上、DNS を切り替えても・時間を置いても自動では解けないことがあります(今回は丸1日待っても解放されませんでした)。

一方、GitHub 公式ドキュメントは「所有ドメインを検証(verify)すれば、他ユーザ/組織の Pages が保持しているカスタムドメインも即座に解放される」と明記しています。エラーメッセージがリンクしているのもこの手順で、確実な解決策はドメイン検証です。Step 3 で TXT を入れてあれば、あとは検証を確定させるだけです。

(なお、その旧ドメインが**別アカウントで既に「検証済み」**の場合は検証による解放はできません。本記事のように所有形態を先に固定し、同一 owner に寄せておけばこの例外は踏みません。)

  1. Organization(または個人)の Settings → Pages → Verified and approved domains → Add a domain
  2. 旧ドメインを入力(Step 3 で控えたトークンの TXT が既に DNS にある状態)
  3. Verify をクリック → 検証完了
  4. リダイレクト用リポジトリのカスタムドメインに旧ドメインを設定
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}'

検証が通っていれば already taken ロックが上書きされ、待たずに取得できます。

:::message alert TXT を DNS に入れただけでは不十分で、GitHub 画面で「Verify」ボタンを押すまで検証は完了しません。「TXT は dig で見えているのに already taken のまま」という状態は、この Verify 待ちであることが多いです。 :::

Step 6: 動作確認

# 転送先が入っているか(新ドメインへの location.replace)
curl -sS https://old.example.com/ | grep -o 'new.example.com'

# 自己消滅 SW が配信されているか(application/javascript で unregister を含む)
curl -sS -o /dev/null -w "%{http_code} %{content_type}\n" https://old.example.com/sw.js

# HTTPS / http→https 301 / 証明書
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}'

証明書が approvedhttps_enforced: true、http→https が 301 なら完了です。旧URLの深いパスも、パスを保持したまま新URLへ転送されます。

:::message 移行直後、旧ドメインを開いて blank や 404 が見えても、多くはブラウザ側に残存する古い SW / キャッシュが原因です。curl -I でサーバ応答(200+転送HTML)を直接確認し、ブラウザ側はスーパーリロード(Mac: Cmd+Shift+R)やプライベートウィンドウで切り分けます。自己消滅 SW を入れてあれば、数回のリロードで自然に解消します。 :::

まとめ

  • 着手前に所有形態を確定する(どのアカウント/org が旧・新ドメインを持つか)。後からの transfer は cross-account 問題を生むので避ける
  • 本体は据え置きで CNAME+絶対URL+データ内の自己参照URLを差し替え。置換は件数で検算する
  • リダイレクトは別の軽量リポジトリ。index.html404.html の両方にパス保持 JS を置く(サーバ側301は不可)
  • PWA なら自己消滅 sw.js を最初から同梱して、残存 Service Worker による乗っ取りを防ぐ
  • DNS 依頼は1回にまとめる(新CNAME・旧CNAME・検証TXT)
  • already taken は GitHub 内部の予約が原因で待っても解けないことがあるドメイン検証(TXT+Verify)で確実に解く。TXT を入れたらVerify ボタンを押すまで完了しない

「所有形態を先に固定 → DNS をまとめて依頼 → already taken は待たず検証で解く」。この順で進めれば、独自ドメインの GitHub Pages 移行を手戻りなく完了できます。