ローカルのパソコンで作ったWebサイトを、世界中から見られる状態にする——いわゆる「本番公開」の作業をしました。手順書に沿えば1時間で終わるはずが、実際には丸一日かかっています。今回はその全過程を、途中で出てくる用語をひとつずつ説明しながら記録します。
想定している読者は、HTML・CSS・JavaScriptは書けるけれど、サーバーやDNSやSSLはこれから、という方です。私自身がその立場で、今回いくつも初めてのことに出会いました。
そもそも何をしようとしているのか
今回のサイトはヘッドレス構成というものです。まずここから説明します。
従来のWordPressサイトは、記事の管理も、見た目の表示も、ぜんぶWordPressがやります。訪問者のブラウザはWordPressに直接アクセスし、WordPressがHTMLを組み立てて返す。1つのソフトが全部を担当する形です。
ヘッドレス構成では、この役割を2つに分けます。
WordPress … 記事を書く・保存する(管理画面とデータベース)
Next.js … 見た目を作る・訪問者に表示する
「ヘッドレス」は「頭がない」という意味で、この場合の”頭”は表示部分を指します。WordPressから表示機能を取り除き、データを保管して受け渡すことに専念させる、というイメージです。
2つに分けると、置き場所も2つ必要になります。
WordPress → お名前.comのレンタルサーバー(cms.kisaku.site)
Next.js → Vercel(kisaku.site)
Vercelは、Next.jsで作ったサイトを公開するためのサービスです。GitHubにコードを置いておくと、それを自動で取ってきて、ビルドして、公開してくれます。
ビルドという言葉も説明しておきます。開発中に書いているコードは人間が読みやすい形になっていて、そのままではブラウザで動きません。これを実際に動く形に変換する作業がビルドです。Next.jsの場合は、この段階でWordPressからデータを取ってきて、ページの形に組み立てるところまで行います。
前回までの記事で、WordPress側の移行は終わっています。今回はNext.js側をVercelに載せ、最後に kisaku.site というドメインを新しいサイトに向けるところまでが目標です。
デプロイ前に見つけた不具合
Vercelに繋ぐ前に、設定ファイルを確認していて問題を見つけました。next.config.mjs の画像に関する設定です。
{
protocol: "https",
hostname: "cms.kisaku.site",
pathname: "/wp-content/uploads/**", // ← ここが間違っていた
}
これが何の設定かというと、Next.jsの画像表示機能が使う「許可リスト」です。
Next.jsには Image というコンポーネントがあり、画像を自動で最適化して表示してくれます。ただし、どこの画像でも無条件に扱うわけではありません。あらかじめ許可したホスト名とパスの画像しか受け付けない仕組みになっています。悪意のあるサイトの画像を勝手に処理させられるのを防ぐためです。
ところが前回の移行作業で、このサイトのWordPressは /wp/ というフォルダの中にあることが判明していました。画像の実際のURLはこうなります。
https://cms.kisaku.site/wp/wp-content/uploads/2026/07/image.png
~~~~ ← これが入る
許可リストのほうには /wp が入っていないので、一致しません。このままだとブログ記事の画像が全部表示されないことになります。
直すのは1行です。
pathname: "/wp/wp-content/uploads/**",
この間違いは、私が以前「WordPressはルートフォルダにある」と誤解していた頃の設定が残っていたものでした。ひとつの誤解が、気付かないうちに別のファイルにも影響していたわけです。誤解が判明した時点で「この前提を使っている場所は他にないか」を探すべきでした。
失敗1: 自動生成されるファイルをGitに入れていなかった
最初のデプロイは20秒で失敗しました。原因はGraphQLの型定義ファイルがリポジトリに入っていなかったことです。
順に説明します。GraphQLは、必要なデータを指定して取り出すための問い合わせ言語です。「記事のタイトルと本文と日付だけください」というように、欲しい項目を書いて送ると、その形で返ってきます。
このプロジェクトでは GraphQL Codegen というツールを使っています。書いたGraphQLの問い合わせを読み取って、TypeScriptの型定義を自動生成してくれるものです。型定義があると、エディタが「このデータにはtitleという項目がありますよ」と教えてくれたり、存在しない項目を書いたときに警告してくれたりします。
これは npm run codegen というコマンドで生成されます。自動生成されるものなので、私は .gitignore(Gitで管理しないファイルを指定するリスト)に入れていました。
ところがVercelは、ビルド時に npm run build しか実行しません。codegenは走らないので、型定義ファイルが存在しないままビルドに入り、そこで落ちるという流れでした。
対処は2つ考えられます。
案A: ビルドコマンドを "npm run codegen && npm run build" に変える
案B: 生成されたファイルをGitにコミットしてしまう
一般論では案Aのほうがきれいです。自動生成物をGitに入れると、差分が読みにくくなり、複数人で開発するときに衝突の原因にもなります。
それでも案Bを選びました。理由は、codegenがWordPressのGraphQLに接続してデータの構造を取りに行くからです。案Aにすると、ビルドが成功するかどうかが本番のWordPressが生きているかどうかに左右されます。
案A: WordPressが落ちている → codegen失敗 → ビルド失敗 → デプロイできない
案B: WordPressが落ちている → ビルドは成功する(表示するデータは無いが)
ビルドは、なるべく外部のサービスに依存しないほうが壊れにくい。 そしてこの判断は、直後に正しかったと証明されます。まさにWordPressへの接続が失敗し続ける事態に陥り、案Aを選んでいたら原因調査のためのデプロイすらできなかったからです。
教科書的な正解と、その場の正解は一致しないことがあります。大事なのはどちらを選ぶかより、選んだ理由を説明できることだと思っています。
失敗2: ビルドは成功。でもデータが1件も出ない
2回目のデプロイは43秒で成功し、サイトも表示されました。ところが中身を見ると、こうなっています。
実績 : まだ実績が登録されていません。
スキル : まだスキルが登録されていません。
ブログ : まだ記事がありません。
プロフィール: (WordPress管理画面で作成すると、ここに表示されます)
WordPress側には投稿7件・実績4件・スキル14件・画像10件が入っています。それが1件も出てきません。
そして厄介なのが、ビルドが「成功」と表示されていることでした。データ取得に失敗しているのに、エラーとして表面化していません。理由はコードにありました。
const [profileResult, projectsResult, skillsResult, postsResult] =
await Promise.allSettled([
getProfilePage(), getProjects(), getSkills(), getPosts(),
]);
const projects =
projectsResult.status === "fulfilled" ? projectsResult.value : [];
Promise.allSettled を説明します。JavaScriptで複数の非同期処理(時間のかかる処理)をまとめて実行する方法は主に2つあります。
Promise.all … 1つでも失敗したら、その時点で全体が失敗になる
Promise.allSettled … 全部の結果を待って、成功・失敗を個別に返す(例外を投げない)
allSettled を選んだのには理由がありました。たとえば「プロフィールページをまだ作っていない」という場合でも、それ以外の部分は表示されてほしいからです。1箇所の欠損でページ全体が真っ白になるのは避けたい。設計としては妥当な判断です。
ただ今回は、4つ全部が失敗しました。それでも設計どおりに空の配列へ置き換えられ、「まだ登録されていません」という平和な画面が出てしまいます。
想定していた「一部が欠ける」ではなく「全部が落ちる」が起きたとき、この防御は問題を隠す方向に働きました。
ここから学べることを一般化すると、こうなります。「壊さない」と「気付ける」は別の要求であり、両立できる。 次の2行があれば、調査は最初の5分で終わっていました。
if (projectsResult.status === "rejected") {
console.error("[getProjects] failed:", projectsResult.reason);
}
エラーを飲み込む設計自体が悪いのではありません。飲み込んだ痕跡を残さなかったことが問題でした。
失敗3: 犯人を推測して、2回外した
接続先の設定を疑いました。GraphQLの接続先はこう書かれています。
const endpoint =
process.env.NEXT_PUBLIC_WORDPRESS_API_URL ?? "http://localhost:8080/graphql";
process.env.○○ は環境変数を読み取る書き方です。環境変数は、コードの外側から値を渡す仕組みで、「開発中はローカルのWordPress、本番は本番のWordPress」のように環境ごとに変わる値を扱うのに使います。
?? はnull合体演算子といって、「左側が空だったら右側を使う」という意味です。つまりこのコードは、環境変数が読めなければ黙ってローカル用のURLにフォールバック(代替)するという動きをします。Vercel上にローカルのWordPressは存在しないので、全部失敗する——筋は通っています。
Vercelの管理画面を見ると、環境変数が Sensitive(機密)という設定で登録されていました。これは値を秘匿するための設定で、登録後は管理画面からも中身が見えなくなります。
ここで違和感を持ちました。この環境変数には NEXT_PUBLIC_ という接頭辞が付いています。Next.jsではこの接頭辞に特別な意味があり、付いた値はビルド時にコードへ直接埋め込まれます。
【ビルド前】process.env.NEXT_PUBLIC_WORDPRESS_API_URL
【ビルド後】"https://cms.kisaku.site/wp/graphql" ← 値そのものに置き換わる
埋め込まれたコードはブラウザに配信されるので、この値は原理的に秘密にできません。だから「公開してよい値にだけ NEXT_PUBLIC_ を付ける」というルールがあり、パスワードやAPIキーに付けてはいけないのもこのためです。
公開前提の値をSensitiveにするのは矛盾している——そう考えて、いったん削除し、Sensitiveなしで登録し直し、再デプロイしました。
結果は変化なし。 データは相変わらず空のままでした。
この時点で、私は原因を2回外しています。しかも困ったことに、Sensitiveだと私自身も値を確認できないので、「URLが間違っているのか」「値が届いていないのか」の区別すらついていませんでした。
観測できない対象を推測で潰しにいっても、当たったか外れたかが分からない。 やるべきだったのは、値を推測することではなく、実際に何が起きているかを表示させることでした。
決め手: エラーを表示させるためだけのコードを書く
方針を変えました。エラーが握りつぶされているなら、握りつぶさずにそのまま表示する経路を一時的に作ればいい、という発想です。
診断専用のURLを1つ追加しました。アクセスすると、次の4つをまとめて返します。
1. ビルド時に埋め込まれた環境変数の実際の値
2. GraphQLクライアントが使っているURL
3. 素のfetchで接続した結果(エラーの中身まで)
4. アプリと同じ経路で実行した結果
コードの要点はここです。
try {
const r = await fetch(endpoint, { method: "POST", /* ... */ });
result.rawStatus = r.status;
} catch (e) {
result.rawError = {
name: e.name,
message: e.message,
cause: String(e.cause), // ← これが決定的だった
};
}
e.cause を取っているのが重要な点です。Node.js(サーバー側でJavaScriptを動かす仕組み)の fetch は、通信の失敗をすべて同じメッセージに丸めてしまいます。
TypeError: fetch failed
名前解決に失敗しても、接続を拒否されても、証明書が一致しなくても、タイムアウトしても、これだけです。本当の原因は cause というプロパティの中に入っています。
これはエラーのラッピング(包装)と呼ばれる設計です。低レベルの細かいエラーを、上位の統一されたエラーで包む。使う側が実装の違いを気にしなくて済む利点がありますが、原因を調べるときは包みを開ける必要があります。
デプロイして、この診断URLを叩いた結果がこれでした。
{
"envRaw": "https://cms.kisaku.site/wp/graphql",
"clientEndpoint": "https://cms.kisaku.site/wp/graphql",
"rawError": {
"message": "fetch failed",
"cause": "Error [ERR_TLS_CERT_ALTNAME_INVALID]:
Hostname/IP does not match certificate's altnames:
Host: cms.kisaku.site is not in the cert's altnames:
DNS:*.gmoserver.jp, DNS:gmoserver.jp"
}
}
SSL証明書の不一致でした。 そして環境変数は正しく届いていたことも同時に分かりました。私が疑って作り直したSensitiveの件は、完全に的外れだったわけです。
なぜブラウザでは繋がり、サーバーからは繋がらないのか
この問題が見つけにくかった理由を説明します。ブラウザからは正常にアクセスできていたのです。鍵マークも付き、GraphQLもデータを返していました。
理解するには、HTTPS通信の最初の手順を知る必要があります。
HTTPSの「S」はSecure(安全)の意味で、通信を暗号化します。ただしその前に、相手が本物かどうかを確認する手続きがあります。これをTLSハンドシェイク(TLSは暗号化の規格名、ハンドシェイクは握手)といいます。
1. こちらが「cms.kisaku.site と話したい」と伝える
2. サーバーが、そのホスト名に対応する証明書を返す
3. こちらが「証明書に書かれた名前」と「アクセス先の名前」が一致するか確認する
4. 一致すれば暗号化通信を開始、しなければ接続を中断
証明書は、身分証明書のようなものです。「このサーバーは確かに cms.kisaku.site です」と第三者機関が保証したデータで、そこには対象のホスト名が書かれています。
今回失敗したのは3の段階でした。返ってきた証明書には *.gmoserver.jp としか書かれておらず、cms.kisaku.site が含まれていない。だからNode.jsは「名乗っている相手が違う」と判断して接続を打ち切りました。
ここで重要なのは、この確認はデータを送る前に行われるということです。実際、Vercelのログには「外部への通信が1本もない」と表示されていました。リクエストを送って失敗したのではなく、送る前の握手で決裂していたのです。
そしてヘッドレス構成では、この違いが致命的になります。
従来のWordPress: ブラウザ ──────────────→ WordPress
今回の構成 : ブラウザ → Next.js(Vercel) → WordPress
↑
ここの通信は、ブラウザからは見えない
従来の構成なら、ブラウザで確認すれば十分でした。しかしヘッドレスではサーバーがサーバーを呼ぶ経路が主役です。私はずっとブラウザから確認して「サーバー側は正常だ」と判断していましたが、その経路は実際に使われる経路ではなかったのです。
「自分の環境から動く」は「どこからでも動く」ではない。 検証は、実際に通信する経路で行わないと意味がありません。
証明書はその後サーバー側で正しく適用され、コードを一切変えずにデータが表示されるようになりました。診断用のURLは役目を終えたので削除しています。
危なかった話: DNSの設定画面を間違えるところだった
サイトが動いたので、最後の工程です。kisaku.site というドメインを、新しいサイトに向けます。
DNSを説明します。インターネット上のサーバーは、本来 216.198.79.1 のような数字(IPアドレス)で識別されます。しかし人間が覚えるのは大変なので、kisaku.site のような名前と数字を対応づける仕組みがあり、これがDNS(ドメインネームシステム)です。電話帳のようなものだと考えてください。
Vercelにドメインを追加すると、設定すべき内容が表示されます。
kisaku.site A 216.198.79.1
www.kisaku.site CNAME e2e46a9cb91ef14a.vercel-dns-017.com
Aレコードは「この名前は、このIPアドレスです」という対応づけ。CNAMEレコードは「この名前は、別の名前の別名です」という指定です。
さて、これをどこで設定するか。お名前.comの管理画面には「DNSレコード設定」という、いかにもそれらしいメニューがありました。開いてみると、入力欄が並んだ画面が出てきます。
ところが、その画面の既存レコード一覧が空でした。いま動いているはずの設定が、1件も表示されません。
ここで手を止めて、確認しました。
kisaku.site のネームサーバー: dns01.gmoserver.jp / dns02.gmoserver.jp
ネームサーバーは、そのドメインの答えを実際に持っているサーバーのことです。DNSには2つの階層があります。
【上位】ネームサーバー … 「このドメインの答えは、どのサーバーが持っているか」
【下位】レコード … 「そのサーバーが持っている、実際の答え」
つまり答えを持っているのはレンタルサーバー側で、私が開いていたのはお名前.comが提供する別のDNSサービスの画面でした。名前がそっくりなので紛らわしいのですが、まったく別物です。
そして画面の下には「レコードの登録とあわせてネームサーバーも変更する」というチェックボックスがありました。もしこれを有効にして保存していたら、上位の階層が書き換わります。
変更前: kisaku.site → gmoserverのDNS(3件のレコードが登録済み)
変更後: kisaku.site → 別サービスのDNS(レコードは空)
参照先が空のほうに切り替わるので、ぶら下がるすべての名前が一斉に消えます。
kisaku.site → 消える
www.kisaku.site → 消える
cms.kisaku.site → 消える ← WordPressごと到達不能に
cms が落ちればGraphQLも止まるので、新サイトもデータを取得できなくなります。「cms のレコードには絶対に触らない」と決めて作業していたのに、1行も触らないまま巻き添えにするところでした。
「レコードを触らなければ安全」は成り立ちません。 上位を切り替えれば下位は丸ごと入れ替わります。作業前には、個々の行を見る前に「いま自分はどのゾーンを編集しようとしているのか」を確認する必要があります。
気付けたのは、一覧が空だったことに違和感を持ったからでした。「あるはずのものが無い」は、たいてい自分が別の場所を見ている合図です。
正しい場所で切り替える
本来の作業場所は、レンタルサーバーのコントロールパネルでした。開くと、期待どおりのレコードが並んでいます。
(なし) 標準 A ← ルートドメイン(kisaku.site そのもの)
www 標準 A
ftp / pop / smtp / imap 標準 A ← メール関連
(なし) MX 10 ← メールの宛先
(なし) TXT ← 送信元の正当性を示す設定
ルートドメインの行を編集すると「別サーバーを利用する」という選択肢があり、IPアドレスを指定できました。ここにVercelの 216.198.79.1 を入れます。
画面には注意書きもありました。「ドメインのホスト名なしはAレコードのみ設定できます」。
これはDNSの仕様上の制約です。理由を説明します。CNAMEは「この名前は別名です」という宣言なので、そのホスト名に他のレコードを共存させられません。ところがルートドメイン(www などが付かない、ドメインそのもの)には、DNSの仕組み上どうしても必要なレコードがあります。
NS … このドメインを管理するネームサーバー(必須)
SOA … ゾーンの管理情報(必須)
MX … メールの宛先(メールを使うなら必要)
これらと共存できないため、ルートドメインにCNAMEは置けないのです。VercelがルートにはAレコード、www にはCNAMEを指定してきたのは、この事情を踏まえたものでした。
www にCNAMEを使うのは、接続先のIPが変わっても自動で追従できるからです。Aレコードで直接IPを書くと、相手のインフラ変更のたびに手で直す必要が出ます。
失敗4: 末尾のドット1文字でエラー
www の設定で、保存に失敗しました。
「指定先」の入力内容が正しくありません。
・ドットの連続やドットとハイフンを連続することはできません。
原因は末尾のドットでした。Vercelが表示していた値は e2e46a9cb91ef14a.vercel-dns-017.com. と、最後にドットが付いています。
これはDNSの正式な記法で、「完全修飾名なので、これ以上ドメイン名を補完しない」 という意味を持ちます。ドットが無いと、システムによっては自動的に自分のドメイン名を後ろに付け足してしまうことがあり、それを防ぐための表記です。
ところがこの入力欄は、ドットを受け付けない実装でした。外して入れ直したら通りました。
情けないのは、私が事前に「入力欄によってはドットでエラーになるので、その場合は外してください」と自分で書いていたことです。注意点を知っていることと、実行時にそれを適用することは別だと痛感しました。
反映を待つ ── 「変わらない」の正しい読み方
DNSを変更した直後に確認すると、まだ古い値が返ってきます。ここで「保存できていないのでは」と疑って設定を触り直すと、事態が混乱します。
DNSの答えにはTTL(Time To Live)という有効期限が付いています。今回は600秒でした。世界中のDNSサーバーは、一度取得した答えをこの秒数のあいだ保存(キャッシュ)し、その間は問い合わせ直しません。毎回問い合わせていたら世界中のDNSが処理しきれないためです。
つまり変更直後に古い値が返るのは正常です。問題は「保存されていない」のか「キャッシュを見ているだけ」なのかをどう区別するかでした。
ここで役に立ったのがTTLの残り秒数です。実際に観測した値を並べます。
kisaku.site A 133.130.64.184 ttl600 ← 取得したばかりのキャッシュ
kisaku.site A 133.130.64.184 ttl218 ← 382秒前に取得された答えを見ている
kisaku.site A 216.198.79.1 ttl600 ← 期限切れ後に取り直した結果
TTLはキャッシュの残り寿命なので、600から218に減っていれば「これは382秒前の答えだ」と分かります。いま見ている情報がいつ時点のものかを読み取れるわけです。ゼロになるのを待って問い合わせ直せば、確実に現在の状態が得られます。
なおブラウザで確認する場合は、パソコンとブラウザ自身のキャッシュも重なります。シークレットウィンドウで開くか、別のネットワークから見るのが手軽です。「反映されない」の多くは、どこかの層にキャッシュが挟まっているだけです。
最終的にこうなりました。
kisaku.site → 216.198.79.1 Vercel
www.kisaku.site → CNAME e2e46a9cb91ef14a.vercel-dns... Vercel
cms.kisaku.site → 133.130.64.184 無事
守りたかった cms は最後まで無傷でした。https://www.kisaku.site/ で新サイトが表示され、SSL証明書も自動で発行されて、公開完了です。
今回の学び
技術的な収穫は、fetch failed の cause を必ず見ることでした。表面のメッセージだけ読んでいる限り、永遠に原因にたどり着けません。包まれたエラーは、包みを開けないと中身が分からない。
進め方の話としては、推測から観測に切り替えるのが遅かったという反省です。今回、原因を2回外しました。どちらも「たぶんこれだろう」で設定をいじり、結果が変わらず、当たったのか外れたのかも判然としない、という時間の使い方です。
最終的に効いたのは、エラーを表示させるためだけのコードを書くという、一見遠回りな手段でした。書くのに10分もかかっていません。推測で1回試すコストと、確実に答えを得るコストは、思っているほど差がない。
そして、DNSのように間違えると広範囲が止まる作業では、操作の前に「いま自分はどの階層を見ているか」を確認する習慣が要る、ということ。レコードを1行も触らずにサイト全体を落としかけた今回は、それを実感する良い機会になりました。
次回は、この作業を通して出てきた用語や仕組みを、もう少し体系立てて解説します。
