Wordpressを止めて静的サイトに移行する方法の例

ここ数ヶ月のハッキング祭りを踏まえ、いい加減Wordpressを止めようかと思い、Publii/Cloudflare連携で実現してみました。その際のメモです。

WordPress(さくらのレンタルサーバ)から Publii + Cloudflare Pages の静的サイトへ移行した記録

約670ページの WordPress サイトを、Publii で静的化し、Cloudflare Pages で公開するまでの経緯と手法をまとめます。DNS はさくらインターネットのまま、メールにも触れずに切り替えました。最終的には、Publii で記事を書いて「同期」を押すだけで、GitHub 経由で Cloudflare に自動反映される運用になっています。

つまずいた点も、原因と対処をそのまま記録しています。同じ構成を考えている方の時間の節約になれば幸いです。

作業日: 2026年10月(計画 10/23、プレビュー 10/27、本番公開と自動反映の完成 10/30) 本文中の仕様や上限、画面の表記は作業時点のものです。実施前に必ず公式ドキュメントで再確認してください。ドメインは example.com に置き換えています。


目次

  1. 背景と目的
  2. 最終的な構成(先に結論)
  3. 全体の流れ
  4. 棚卸しと設計
  5. コンテンツの移行(Publii)
  6. 品質確認と削除の判断
  7. Publii の「同期」だけで公開できる構成にする
  8. 旧 URL の 301 転送
  9. GitHub と Cloudflare をつなぐ
  10. DNS の切替
  11. 切替後の検証
  12. 日々の運用と障害時の見方
  13. つまずき集
  14. 学んだこと
  15. 付録

1. 背景と目的

  • 対象は、弁護士個人の専門サイト(IT・知的財産分野の解説記事、判例紹介、Q&A など)です。約10年前は検索で上位に表示されていましたが、数年間ほぼ閉鎖状態にしていた時期に順位が崩れました。
  • 構成は、さくらのレンタルサーバ上の WordPress です。固定ページと記事を合わせて約670ページありました。
  • 目的は次の3点でした。
    1. 運用と保守の負担を減らす(WordPress の更新、プラグイン、脆弱性対応をなくす)。
    2. 表示速度と安定性を上げる。
    3. 2026年の検索環境を前提に、情報構造を作り直す。
  • 方針は「原因探しをしない」ことでした。順位が崩れた原因を調べるのではなく、あるべき構造を設計し直す方針です。順位の回復が遅いことは許容しました。

検討した選択肢

案評価
さくらのまま WordPress を運用変更が最小ですが、保守負担は残ります。
さくらに静的サイトを置く301 は .htaccess で可能と判断しました。ただし旧 URL のクエリ文字列(?p=ID)の転送は、実測が必要でした。
Cloudflare Pages で公開無料枠で足り、配信が速く、切り戻しも簡単です。転送ルールも自由に書けます。採用しました。

検索順位は、ホスティング会社の違いで直接は変わりません。差が出るのは、表示速度、安定性、運用面です。


2. 最終的な構成(先に結論)

 Publii(記事を書く)
    │  「同期」ボタン(Publii の GitHub Pages 同期機能)
    ▼
 GitHub(private リポジトリ・main)
    │  push を検知
    ▼
 Cloudflare Workers Builds(ビルド環境)
    │  Deploy command:
    │  npx wrangler pages deploy . --project-name <プロジェクト名> --branch main
    ▼
 Cloudflare Pages(本番)
    ▲
    │  CNAME(www だけ)
 さくらの DNS(ネームサーバーはそのまま。apex とメールも、さくらのまま)

ポイントは次の4つです。

  1. DNS の移管はしません。 www だけを CNAME で Cloudflare Pages に向けます。メールには影響しません。
  2. apex(example.com)から www への転送は、さくら側の .htaccess で独立させました。 実は WordPress 本体がしていた転送でした。
  3. 記事一覧ページは、Publii の「著者アーカイブ」で作りました。 ビルド用の特別なスクリプトなしに、標準の出力だけでサイト全体が再現できます。
  4. 旧 URL の転送(約1,400ルール)は、root-files に置いたファイルで配信します。 同期のたびに GitHub に入り、Pages に載ります。

3. 全体の流れ

1. 棚卸し・設計        全ページの台帳、Search Console の分析、URL 設計
2. コンテンツ移行      WordPress から WXR を生成して Publii に取り込む
3. 品質確認            法令・事実の確認、削除する記事の判断
4. 標準出力の整備      Publii の標準出力だけで本番を再現できるようにする
5. 301 転送の実装      旧 URL から新 URL へ
6. 接続               Publii → GitHub → Cloudflare Pages の自動化
7. DNS 切替           www だけを Cloudflare に向ける
8. 切替後の検証と運用

実際の順序は一直線ではありませんでした。先に特別なビルドスクリプトで本番を公開し、あとから Publii だけで再現できる形に直しました。 この回り道の経緯は、第7章で詳しく書きます。

作業の多くは AI コーディングエージェント(Claude Code)に任せました。ただし、公開、DNS の変更、Git の commit や push、外部への送信など、副作用のある操作は、人間の指示があるときだけ実行する運用にしました。


4. 棚卸しと設計

4.1 全ページの台帳を作る

WordPress の REST API から、公開済みの記事と固定ページを全件取得し、CSV の台帳にまとめました。

  • 記録した項目: ID、日付、カテゴリ、タイトル、文字数、クリック数、表示回数、インデックス状況、画像数、外部リンク数、旧 URL。
  • 台帳があると、「どの記事を残すか、統合するか、削除するか」を、感覚ではなく数字で判断できます。

4.2 Search Console の分析

16か月分のデータをエクスポートして分析しました。

  • 月間クリックは約150から約310に増えていました。平均掲載順位は約30から約9に改善していました。
  • 一方、671ページのうち450ページが未インデックスでした。判例の記事は230件中182件が未インデックスでした。
  • クリックの上位は、サイトの主題とは別の開発関連の1記事と、個人情報に関する Q&A でした。

「稼いでいる記事」を壊さないことが、転送設計の優先事項になりました。

4.3 URL 設計

  • 旧 URL の維持は不要としました。ただし、現行 URL、/blog/YYYY/MM/DD/… 形式、?p=ID 形式は、すべて 301 で新 URL に転送する方針にしました。
  • 記事は「フラット」な URL(/記事スラッグ/)にしました。 Publii が記事スラッグ中の / を取り除くためです。階層構造は、固定ページ(ハブ、サービス案内)にだけ使いました。
  • ハブページを5つ作る構成にしました。ほかに、ノート、業務案内、記事一覧を置きました。
  • 非本業の記事(クイズ25件など)は、noindex にして同じサイトに残しました。

5. コンテンツの移行(Publii)

5.1 Publii を選んだ理由

  • GUI で記事を管理でき、静的 HTML を出力できます。
  • WordPress のエクスポート形式(WXR)を取り込めます。
  • GitHub Pages 向けの同期機能があり、GitHub にコミットとして出力を送れます。(最終的な運用の要になりました。)

5.2 WXR を自前で生成した

WordPress からのエクスポートをそのまま使わず、台帳と REST API のデータから スクリプトで WXR を生成しました。

  • 壊れた HTML 属性の修復、消失した画像の除去、内部リンクの正規化を、一括で行えます。
  • 削除する記事の一覧をスクリプトの定数にして、再生成すれば必ず同じ結果になるようにしました。

5.3 Publii で実際につまずいた点

現象対処
記事スラッグ中の / が除去される階層 URL を諦め、記事はフラットな URL にしました。
日本語タグのスラッグがピンインに変換されるPublii を閉じた状態で db.sqlite をバックアップし、tags テーブルのスラッグを SQL で英語に修正しました。Publii が開いている間は DB に触れないでください。
取り込みレポートに「Redirect map」「shortcode 警告」などが出る誤検知と判断して無視しました。重要なのは、IMAGES 欄が空であること(画像の取りこぼしがないこと)でした。

5.4 画像

2015〜2016年の画像のうち約51枚は、サーバー側にもバックアップにもなく、復元できませんでした。画像を除去し、画像を前提にした文も修正しました。古いサイトほど、移行の前に消失画像の洗い出しをしておくべきです。


6. 品質確認と削除の判断

専門性の高い記事を扱うサイトなので、移行を「内容を見直す機会」にしました。

  • 約640記事を対象に、外部エージェントに法令改正の確認と改訂案の作成をさせました。公開本文にはすぐ反映せず、差分の機械比較、抜取り検査、混入文言の検索を行いました。編集者向けの文言が公開文に混入していた10件を見つけて修正しました。
  • 判例を紹介する記事の中に、実在性や同一性を確認できないもの、別事件の内容に置き換わっているものがありました。9件を削除し、転送せずに 404 としました。
  • 判例の要約や法令の時点の正確さは、AI が保証できません。未確認の記事に「弁護士確認済み」と表示することはせず、確認が済むまで表示を出さない方針にしました。

教訓: AI が生成または補完した可能性のある記事には、事実の誤りが紛れ込みます。移行は全件の事実確認をする良い機会です。削除した記事は、無理に別ページへ転送せず、404 にするほうが正確な場合があります。


7. Publii の「同期」だけで公開できる構成にする

この章が、後から読んで最も役に立つと考えている部分です。

7.1 最初は特別なビルドスクリプトで公開した

最初の本番公開は、Publii の内部モジュールを呼び出す自作のビルドスクリプトで出力し、wrangler で直接アップロードしました。Publii の標準出力では出せないものがあったからです。

  • 記事一覧ページ(Publii の通常のページには、記事の一覧を渡せません)。
  • DB 上は下書きのままだった FAQ ページ。
  • 旧 URL の転送ファイル群。
  • 本番用のドメイン設定。

この方式の問題は、記事を1本公開するたびに、スクリプトの実行とデプロイを人間が手で行うことでした。「Publii の画面から実施できる仕組み」が望ましいという要望を受けて、標準の出力だけで同じサイトを再現する作業を始めました。

7.2 標準出力と本番の差を「測る」

サイトの複製を作り、Publii の標準レンダラーだけで出力して、現在の本番と比較しました。 本物のサイトや DB には触れずに、何が足りないかを数値で測れます。

比較は、HTML の空白を正規化したうえで、行ごとの差を集計して行いました。差が「意図した変更」だけになるまで調整を繰り返しました。

7.3 差の対処

差対処
フィード(RSS/JSON)のリンクサイト設定で無効化
FAQ ページが出力されないDB のステータスを draft から published に変更しただけで、標準出力に含まれた
転送ファイル(_headers、_redirects、_worker.js、_routes.json)Publii のサイトフォルダの input/root-files/ に置く。出力のルートにそのままコピーされる(バイト単位で一致を確認)
サイトのドメインが未設定(FTP 試験の名残)https://www.example.com を設定
記事一覧ページ下の 7.4

root-files の注意: Publii は、ドットで始まるファイル(.assetsignore など)を同期の対象にしません。.htaccess など一部だけ例外です。ドットで始まるファイルに頼る構成は避けてください。

7.4 記事一覧ページ(最大の課題)

トップとは別に、「紹介文なしで、カテゴリ別リンクと全記事のページ送りだけ」を表示する /articles/ を作りたい、という要件でした。

方式結果
固定ページに記事の一覧を埋め込むPublii の固定ページには、記事のデータが渡されない。不可。
Publii 標準の postsPrefix(記事一覧の URL 接頭辞)不可。 設定すると全記事の URL が /articles/記事名/ に変わった(複製で実験して発覚)。633記事の URL が変わってしまう。
固定ページをトップにして、記事一覧を別 URL に置く不可。トップの「新着記事」表示が消え、要件に反した。
著者アーカイブを記事一覧として使う可。 Publii が全記事をページ送り付きで出力する標準機能。

著者アーカイブ方式の設定は次のとおりです。

設定値
著者のユーザー名(URL になる部分)all
著者の URL 接頭辞(authorsPrefix)articles → 記事一覧は /articles/all/
著者の metaRobots(著者の追加データ)index, follow(著者ページは既定で noindex になりがち)
authorNoIndexPaginationfalse(ページ送りも検索に載せる)
テーマの「著者ごとの1ページの記事数」トップと同じ値(10)に揃える
トップの homepageNoPaginationtrue(トップのページ送りを廃止。一覧と重複させない)

URL を /articles/ にしなかった理由: authorsPrefix を空にして /articles/ にすることもできます。しかしその場合、ページ送りのリンク、rel="next"、og:url が https://www.example.com//articles/ と二重スラッシュになりました。これは Publii 本体の生成部分で、テーマからは直せません。URL を /articles/all/ にして、二重スラッシュを避けました。

著者アーカイブ方式の副作用と対処:

副作用対処
記事内の著者名リンクが、無効なページ(/articles/all/)を指すテーマのテンプレートで、<a> を <span> に置き換えた(著者名は表示したまま、リンクをなくす)
JSON-LD の著者 URL が著者ページになるサイト設定「著者のウェブサイトを JSON-LD に使う」を有効にし、著者の website にプロフィールページを設定
一覧ページ独自の title・description・canonical・og タグテーマの head.hbs で、著者ページのときだけ専用の partial を使い、Publii 標準の og タグ・JSON-LD を抑止
旧 /page/N/(トップのページ送り)が消える転送ルールを足して /articles/all/page/N/ に 301
旧 /articles/同じく /articles/all/ に 301

結果、標準出力と本番の差は、メニューのリンク、著者リンクの表示、JSON-LD の著者 URL、一覧の URL・canonical だけになりました。

7.5 HTML 圧縮の設定に注意

標準出力と本番を比べたとき、ほぼ全ページで差が出ました。原因は、htmlCompressionを意図せず false にしたことでした(既定値は true)。本番が圧縮された HTML だったので、設定をそろえる必要がありました。差分が大量に出たら、まず空白・圧縮・属性の書式を疑ってください。


8. 旧 URL の 301 転送

8.1 規模

  • 転送ルール約1,400本(固定ページ、旧日付 URL、/blog/ 形式、互換用の旧 URL、トップのページ送り)。
  • WordPress の ID は665件を対応させました。削除した12件の ID は 404 のままです。
  • 旧 URL は日本語のパーセントエンコードを含むため、一致処理では、デコードして Unicode 正規化(NFC)したうえで比較する必要がありました。

8.2 _redirects だけでは足りない

Cloudflare Pages の _redirects ファイルには、静的ルールを書けます(作業時点の上限は2,000件)。ただし、クエリ文字列(?p=123 など)を条件にした転送は書けません。

そこで、次の構成にしました。

  1. 静的に決まる転送は _redirects に書く。
  2. ?p=ID と ?page_id=ID の転送は、**Pages Functions の高度なモード(_worker.js)**で処理する。
  3. 画像や CSS などの静的ファイルは、_routes.json でワーカーの対象から除外する(ワーカーの呼び出し回数を節約)。

ワーカーの概念コード:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const pathname = normalizePath(url.pathname);   // デコード + NFC + 末尾スラッシュ除去

    // 旧入口 + ?p=ID / ?page_id=ID
    if (wpEntrypoints.has(pathname) && (url.searchParams.has('p') || url.searchParams.has('page_id'))) {
      // 数字以外・矛盾する値・削除済み ID は 404。トップへは逃がさない
      // 存続する ID は 301 で新 URL へ(p / page_id 以外のクエリは引き継ぐ)
    }
    if (removedPaths.has(pathname)) return notFound(...);  // 削除記事は 404 を維持
    if (paths.has(pathname)) return redirect(...);          // 旧パスから新パスへ 301
    return env.ASSETS.fetch(request);                        // それ以外は静的ファイル
  },
};

設計上の注意点:

  • 転送先は、必ず同じオリジンに限定しました。 対応表の誤りで外部サイトに飛ばさないための安全策です。
  • 不正な ID、矛盾する値、削除済みの ID は、トップに逃がさず 404 にしました。 ソフト 404(存在しないのにトップを返す)を避けるためです。
  • これらの対応表は、生成スクリプトで作ります。 手書きにすると、記事数が多いサイトでは間違いが必ず入ります。生成後に、すべての転送先が実在するかを機械的に確認しました。

8.3 転送先の存在確認

  • 旧台帳の転送先のうち、新サイトに存在しないものが38件ありました。現在の URL に解決し直しました。
  • プレビューの段階で、実 URL を検査して、転送元3,021件がすべて成功し、転送先634ページが 200 を返すことを確認しました。
  • 本番公開後は、転送元から60件を抽出して 301 を確認しました。削除した49件の ID・パスは、すべて 404 でした。

9. GitHub と Cloudflare をつなぐ

ここは落とし穴が多い章でした。

9.1 Publii の GitHub Pages 同期の設定

Publii のサーバー設定で「GitHub Pages」を選びます。各欄の意味:

欄入れる値
Username / organizationリポジトリを持つGitHub のアカウント名(または組織名)。リポジトリ URL が github.com/<ここ>/<リポジトリ> なら、<ここ> の部分
Repositoryリポジトリ名だけ(<ユーザー名>/ や URL は付けない)
Branchmain
Tokenfine-grained personal access token。このリポジトリ1件に限定し、Contents: Read and write を付ける。有効期限を設定する
カスタムドメイン(CNAME)空欄。ドメインは Cloudflare 側で設定する

トークンは、AI エージェントに渡さず、Publii の画面に直接入力します。

9.2 ハマった点: 空のリポジトリで接続テストが失敗する

接続テストで次のエラーが出ました。

HttpError 409 ... GET /repos/<user>/<repo>/git/ref/heads/main
"message": "Git Repository is empty."
  • 認証やリポジトリ名は正しく、main ブランチがまだ存在しないことが原因でした(認証エラーなら 401、リポジトリがなければ 404 になります)。
  • 対処は、最初のコミットを1つ作って main を作ることです。GitHub の画面の「Create new file」で README.md を作っても、gh api で Contents API を使って作っても構いません。
  • この README は、Publii の次の同期で自動的に消されます(サイトにない他のファイルは、同期で削除されます)。

9.3 Publii の同期が GitHub に作るもの

  • 同期のたびに、「Updated from Publii」というコミットが作られます。内容に変更がなければ、コミットは作られません。
  • 出力に含まれない、リポジトリ側だけのファイルは削除されます。GitHub Actions のワークフロー(.github/ 以下)などを置いても、同期で消える可能性があります。 ドットで始まるファイルは Publii の管理対象外なので、確実とは言えません。
  • CNAME ファイルが作られます(中身はドメイン名)。Cloudflare Pages は無視しますが、サイト上の /CNAME から取得できます。無害ですが、気になる場合はワーカーで 404 にできます。

9.4 Cloudflare 側: 「Pages の Git 連携」のつもりが、実は Workers Builds だった

Cloudflare のダッシュボードで GitHub のリポジトリをつなぎ、自動デプロイを有効にしました。ところが、push しても Pages のデプロイ一覧に何も増えません。GitHub のコミットには、次のチェックが付いていました。

Workers Builds: <プロジェクト名>   conclusion: success

ビルドのログを読んで、原因が分かりました。

Executing user deploy command: npx wrangler deploy
Detected Project Settings:
 - Worker Name: <プロジェクト名>
 - Framework: Static
 - Output Directory: .
▲ [WARNING] `_worker.js` in the project root will not be deployed: the project is configured as static assets ...
...
https://<プロジェクト名>.<アカウント>.workers.dev
  • この Git 連携は、Pages ではなく Workers(静的アセット)として動いていました。 公開先は workers.dev で、www.example.com に紐づく Pages の本番は更新されません。
  • 静的サイトとして自動判定されたため、_worker.js(旧 URL の ?p=ID 転送など)は使われませんでした。
  • 副作用として、workers.dev に、サイト全体のコピーが公開されました(canonical は本番を指していて害は小さいものの、不要な重複公開です)。

9.5 対処: Deploy command を Pages 向けに書き換える

ビルド環境で、Pages へのデプロイを実行するように変えました。Worker の Settings → Builds にある Deploy command を次にします。

npx wrangler pages deploy . --project-name <プロジェクト名> --branch main
  • Build command は空欄のままです。Publii がすでに完成した HTML を出力しているため、ビルドは不要です。
  • --branch main が、Pages の本番ブランチと一致すると、Production 環境へのデプロイになります。

最初に試すと、次のエラーで失敗しました。

A request to the Cloudflare API (/accounts/.../pages/projects/<プロジェクト名>) failed.
Authentication error [code: 10000]
It looks like you are authenticating Wrangler via a custom API token set in an environment variable.
Please ensure it has the correct permissions for this operation.
  • 原因は、ビルド用の API トークンに Pages の権限がなかったことです(Workers の権限しかなかった)。
  • 対処は、ダッシュボードの profile/api-tokens で、そのトークンの権限に Account › Cloudflare Pages › Edit を追加することでした。トークンの値は変わらないので、Worker 側の設定変更は不要です。
  • 権限を足してビルドを再実行したところ、約20秒でデプロイに成功し、Pages の Production に新しいデプロイが作られました。

教訓: 「Git 連携を有効にした」だけでは、どの製品(Pages か Workers か)で動くかが分かりません。最初の push 後に、ビルドのログで Executing user deploy command の中身と、公開先を必ず読んでください。

9.6 ビルドを起動するための「空のコミット」

設定を変えただけでは、ビルドは走りません。push が必要です。中身を変えずにビルドを起動するには、空のコミットを作ります。

# 同じツリーを指す新しいコミットを、main の先頭に作る
HEAD=$(gh api repos/<user>/<repo>/git/ref/heads/main --jq .object.sha)
TREE=$(gh api repos/<user>/<repo>/git/commits/$HEAD --jq .tree.sha)
NEW=$(gh api -X POST repos/<user>/<repo>/git/commits \
        -f message="Trigger build (no content change)" -f tree="$TREE" -f "parents[]=$HEAD" --jq .sha)
gh api -X PATCH repos/<user>/<repo>/git/refs/heads/main -f sha="$NEW"

Publii 側で内容が変わらない同期は、コミットを作らないので、ビルドは起動しません。

9.7 Worker 一本化(Pages をやめる)案は、条件付きで不可

Cloudflare が Pages の後継として勧める「静的アセット付き Worker」に一本化する案も試しました。テスト用 Worker にデプロイして、転送・404・ヘッダー・非公開ファイルの扱いをすべて検証し、問題なく動くことを確認しました。

ただし、www.example.com を Worker のカスタムドメインにするには、そのドメインのゾーンが Cloudflare にある必要がある(作業時点の認識)ため、DNS をさくらに残す今回の方針とは両立しませんでした。Pages なら、外部 DNS の CNAME だけで済みます。

DNS を Cloudflare に移せるなら、Worker 一本化のほうが、設定がリポジトリ内に収まり、トークンの権限問題も生じません。DNS をほかの事業者に残す必要があるなら、Pages に Workers Builds から wrangler pages deploy する今回の構成が現実的です。


10. DNS の切替

10.1 「DNS 移管は必須」ではない

Cloudflare Pages のカスタムドメインの要件は、公開するホスト名で異なります(公式ドキュメントで確認)。

公開するホスト名要件
apex(example.com)そのドメインが Cloudflare の zone であること。ネームサーバーを Cloudflare に向ける必要があります。
サブドメイン(www.example.com)zone である必要はありません。外部 DNS で CNAME を作れば足ります。

このサイトでは、メールを同じサーバー(さくら)で受けていました。MX が apex ドメインを指しているため、ネームサーバーごと移すと、メールの設定を移し替える必要が生じます。そこで、ネームサーバーはさくらのままにして、www だけを CNAME で Pages に向ける方式を選びました。

10.2 apex から www への転送を、誰がしているのか

もともと example.com は www.example.com に自動で転送されていました。この転送の担当は、次の手順で突き止めました。

curl -sI https://example.com/
# HTTP/2 301
# location: https://www.example.com/
# x-redirect-by: WordPress     <- これが決め手
  • x-redirect-by: WordPress は、**WordPress 本体の標準機能(canonical redirect)**による転送であることを示します。.htaccess には、ホストに関する記述がありませんでした。
  • このままだと、旧 WordPress を削除または停止したときに、apex から www への転送も消えます。

そこで、さくら側の .htaccess に、WordPress のブロックの外側へ独立した転送ルールを追加しました。

# BEGIN apex to www (WordPress のマーカーの外に置く)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^(.*)$ https://www.example.com/$1 [R=301,L]
</IfModule>
# END apex to www
  • WordPress のマーカー(# BEGIN WordPress から # END WordPress)の内側は、WordPress が自動で上書きします。必ず外側に書いてください。
  • 最初は %{REQUEST_URI} と NE フラグを使いましたが、日本語パスが %エンコードされず、生の UTF-8 で Location に出ました。 さくらの前段の nginx が、デコード済みの値を渡していたためと考えられます。上のように ^(.*)$ と $1 を使う書き方に直すと、正しく %エンコードされました。
  • 反映後は、x-redirect-by: WordPress が消えていることを確認します。

10.3 切替の手順

  1. Cloudflare の Pages で、先にカスタムドメイン www.example.com を追加する。 追加せずに CNAME だけ向けると、522 エラーになります。
  2. さくらの DNS で、www の CNAME の TTL を短縮する(今回は 3600 秒から 300 秒)。短縮前に各地のキャッシュに残った情報は、元の TTL 分だけ生き残ります。切替の1時間以上前に行います。
  3. www の CNAME の値を、旧サーバーから <プロジェクト名>.pages.dev. に変更する。
  4. 検証する。切り戻しが必要になれば、CNAME を元の値に戻します。

10.4 ハマった点: CNAME の末尾ドット

さくらの DNS 設定画面で、CNAME の値を <プロジェクト名>.pages.dev と入力しました。末尾のドットがなかったため、さくら側は自ドメインの相対名として解釈し、次の値になりました。

www.example.com.  CNAME  <プロジェクト名>.pages.dev.example.com.   <- 存在しない名前

結果として、約40分間、www が解決できない状態になりました。

  • 原因は、DNS の値では、末尾のドットで「絶対名」を示すことでした。ドットがないと、ゾーンの名前が補われます。
  • 発見した方法は、dig +short CNAME www.example.com @<さくらのネームサーバー>で、登録された値を直接確認したことです。

教訓: DNS を変更した直後は、必ず権威ネームサーバーに直接問い合わせて、登録された値を確認してください。ブラウザで開くだけでは原因を特定できません。

10.5 証明書とキャッシュ

  • CNAME が正しくなったあと、Cloudflare が検証して証明書を自動発行しました。数分間は TLS が確立せず、一時的に接続エラーが出ました。
  • 手元の Mac のリゾルバには、誤った値のキャッシュが残りました。TTL の経過を待つか、curl --resolve で IP を直接指定すれば、先に動作を確認できます。

11. 切替後の検証

# 証明書
echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject -enddate

# 主要ページ・404・旧 URL の転送
for p in / /faq/ /articles/all/ /sitemap.xml /robots.txt /nonexistent/ "/?p=123" /page/2/; do
  curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" "https://www.example.com$p"
done

# apex から www へ(http / https 両方)
curl -s -o /dev/null -L -w "%{http_code} final=%{url_effective} hops=%{num_redirects}\n" http://example.com/

# 内部ファイルが公開されていないこと
for p in /_worker.js /_redirects; do curl -s -o /dev/null -w "%{http_code}\n" "https://www.example.com$p"; done

# メール関連の DNS が変わっていないこと
dig +short MX example.com; dig +short TXT example.com

確認できたこと:

  • 証明書が有効で、自動更新されること。
  • 主要ページが 200、存在しないパスが 404 であること。
  • 旧 URL(旧 ID、日付 URL、/blog/…)が、新 URL へ 301 で転送されること。削除した記事の ID・パスは 404 のままであること。
  • http:// と https:// の apex が、www に到達すること。
  • sitemap.xml が、インデックス可能なページと過不足なく一致すること(noindex ページを含まない)。
  • MX、SPF、A レコードが変わっていないこと。ただし、メールの実際の送受信は別途テストが必要です。

12. 日々の運用と障害時の見方

12.1 記事を公開する流れ

  1. Publii で記事を書き、スラッグを英語にして、「公開」にします(日本語のままだとローマ字変換になります)。
  2. 「同期」を押します。
  3. 数十秒〜数分で、Cloudflare Pages の Production に新しいデプロイが作られます。
  4. 公開 URL を開いて確認します。

12.2 テスト記事に注意

動作確認用に作った記事は、公開のままにすると、そのまま本番に出ます。確認が終わったら、必ず下書きに戻すか削除してください。 今回も一度、同期でテスト記事が GitHub に載りました(本番への反映の前だったので、被害はありませんでした)。

12.3 同期したのに反映されないとき

  1. GitHub のコミット履歴に、「Updated from Publii」が増えているか。
  2. そのコミットのチェック「Workers Builds: …」が成功か失敗か。
  3. 失敗なら、ビルドのログの末尾を読む。Authentication error なら、API トークンの権限か有効期限。
  4. Cloudflare の Deployments に、Production のデプロイが増えているか。

ビルドが失敗しても、本番は直前の状態のままです。サイトが止まることはありません。

12.4 切り戻し

  • Cloudflare Pages の Deployments から、以前のデプロイを選んで「Rollback」で戻せます。
  • DNS ごと戻す場合は、www の CNAME を元の値に戻します(TTL 300秒なら5分程度)。

13. つまずき集

事象原因対処
記事スラッグの / が消えるPublii の仕様フラットな URL にする
タグのスラッグがピンインになる日本語タグの変換DB を直接修正(Publii を閉じて、バックアップしてから)
postsPrefix を設定したら全記事の URL が変わったPublii の仕様(接頭辞が記事 URL にも付く)使わず、著者アーカイブで代用
authorsPrefix を空にしたら URL が // になるPublii 本体の URL 組み立て接頭辞を空にしない
標準出力と本番で全ページに差が出るhtmlCompression の設定違い本番と同じ値にそろえる
空のリポジトリで接続テストが 409main ブランチが未作成最初のコミットを作る
push しても Pages にデプロイが出ないGit 連携が Workers Builds で動いていたDeploy command を wrangler pages deploy に変更
デプロイで Authentication error [code: 10000]ビルド用トークンに Pages の権限がないトークンに Cloudflare Pages: Edit を追加
設定を変えたのにビルドが走らない変更だけでは起動しない空のコミットで起動
www が見えなくなったCNAME の末尾ドットの付け忘れ値の最後に . を付ける
切替直後に TLS エラー証明書の発行待ち数分待つ
日本語パスの Location が %エンコードされない.htaccess の %{REQUEST_URI} + NE^(.*)$ と $1 を使う
apex の転送が WordPress 依存だったWordPress の canonical redirect.htaccess に独立ルールを追加
テスト記事が本番に載った公開状態のまま同期確認後に必ず下書きに戻す

14. 学んだこと

  1. 台帳を作ってから設計する。 全ページのクリック、インデックス状況、文字数を並べると、残す、統合する、削除するの判断がしやすくなります。
  2. ツールの制約に合わせて URL を設計する。 Publii がスラッグの / を除去する以上、フラットな URL のほうが無理がありません。
  3. WXR は自前で生成する。 修正と再生成を、何度でも同じ手順で繰り返せます。
  4. 特別なビルドスクリプトは、あとから標準機能に戻せる。 ただし、戻すときは、複製したサイトで標準出力を作って本番と数値で比較してください。本物のサイトに触れずに、差を一つずつ潰せます。
  5. 実験してから決める。 postsPrefix のように、名前から想像した動作と実際の動作が違う設定があります。複製で試せば、数分で分かります。
  6. 転送は仕様から設計する。 _redirects はクエリ文字列を扱えません。?p=ID を拾うには、ワーカーが必要です。日本語 URL は、デコードして正規化してから比較します。
  7. ドメインの要件は、公開するホスト名と、使う製品(Pages か Workers か)で決まる。DNS の移管が必要なのは apex の場合だけで、Workers にはゾーンの移管が必要です。メールを同じドメインで受けているなら、www だけを Pages に向ける方式が安全です。
  8. 旧サイトが担っていた役割を洗い出す。 旧 WordPress は、apex から www への転送という、見えない役割を担っていました。curl -I のヘッダー(x-redirect-by)が手がかりになります。
  9. 「連携した」ではなく「何が動いたか」をログで確認する。 自動デプロイが有効になっていても、動いたのが別の製品のビルドだったことがあります。
  10. DNS の値は、権威サーバーに直接問い合わせて確認する。 末尾ドットのような細部で、サイトが止まります。TTL の事前短縮も忘れずに行います。
  11. 削除の判断を恐れない。 実在が確認できないページは、公開し続けるリスクのほうが大きいです。
  12. 副作用のある操作は、人間の指示で実行する。 AI エージェントに作業を任せる場合も、公開、DNS、Git の commit や push、外部送信は、確認を挟む運用にしました。トークンは、AI に渡さず、画面に直接入力します。

15. 付録

用語

用語説明
WXRWordPress のエクスポート形式(WordPress eXtended RSS)。
Publii静的サイトジェネレーター兼 CMS(デスクトップアプリ)。
root-filesPublii のサイトフォルダ内の、出力のルートにそのままコピーされるファイルの置き場。
Cloudflare Pages静的サイトのホスティング。Git 連携と直接アップロードに対応します。
Workers BuildsCloudflare の、Git 連携のビルド環境。今回、Pages へのデプロイを実行する場として使いました。
wranglerCloudflare の CLI。Pages へのアップロードに使います。
apexホスト名のないドメイン本体(example.com)。
TTLDNS の応答をキャッシュしてよい秒数。
canonical redirectWordPress が、設定したサイトアドレスと異なるホスト名へのアクセスを、正規のアドレスに転送する機能。
著者アーカイブPublii が著者ごとに作る、全記事のページ送り付き一覧ページ。

最終的な構成要素の一覧

場所役割
Publii のサイト設定ドメイン、著者の接頭辞、フィード無効、HTML 圧縮
Publii のテーマ記事一覧のテンプレート、著者リンクの置き換え、一覧用の head
Publii の root-files/_headers、_redirects、_worker.js、_routes.json
GitHub出力を保存。Publii が自動でコミット
Cloudflare Workers BuildsDeploy command: npx wrangler pages deploy . --project-name … --branch main
Cloudflare のビルド用 API トークン権限: Workers 系に加えて Cloudflare Pages: Edit
Cloudflare Pages本番。www のカスタムドメインを登録
さくらの DNSwww の CNAME、apex の A、MX、SPF
さくらの .htaccessapex から www への 301(WordPress と独立)
2,204 words·10 min read·~2.9k tokens