個人技術ブログを S3+CloudFront から Cloudflare Pages に移行した記録──Next.js 16 で詰まったポイントと実運用差分
このブログ(cloud-and-code.com)は、もともと S3 + CloudFront + ACM + Route 53 という AWS 構成でホスティングしていました。当時の構築記は別記事 試験合格の知識を即実践!アラフォーエンジニアがレガシーを脱して挑戦するS3+CloudFront+Next.jsブログ構築記 にまとめています。
それを、2026 年 5 月に Cloudflare Pages にまるごと移行しました。本記事は、その移行作業を実際に行った記録です。
似たような構成移行の解説記事は世の中にもありますが、本記事の差別化ポイントは以下です。
- Next.js 16 + App Router + 静的エクスポート という、移行記事がまだ少ない構成での実例
- 移行作業中に実際に踏んだ詰まりどころ 5 件を、ログ・コマンド・解決策つきで記録
curlで取った HTTP ヘッダーの Before/After を生で並べているので、配信経路がどう変わったかが視覚的にわかる
「これから個人ブログや小規模サイトを Cloudflare Pages に移そうとしているが、Next.js だと何が起きるか不安」という方の参考になれば幸いです。
なぜ移行したか
このブログは AWS の学習目的で立ち上げ、構築過程で SAA で得た知識を実装に落とし込むのが当初の目標でした。立ち上げから 1 年弱たち、その目的はかなり達成できた感覚があります。
そのうえで、運用フェーズに入ったいま、以下のような声を耳にする機会が増えました。
- 「個人の小規模静的サイトなら Cloudflare Pages で十分」
- 「ビルドからデプロイまでが
git pushで完結する」 - 「アクセス量が読めない個人サイトは、無料枠の太い Cloudflare のほうが安心」
実際にこのブログ程度の規模(月数百〜数千 PV、ほぼ静的)であれば、AWS の請求書はほぼ無視できる金額ですが、deploy.sh で SSO ログイン → ビルド → S3 同期 → CloudFront インバリデーションを毎回回すフローは、ちょっと面倒に感じるようになっていました。
「AWS 学習はこの先も継続する。ただし、ブログそのものの運用は身軽にしたい」
そう判断して、ホスティング先を Cloudflare Pages に切り替え、その移行体験そのものを次の学びと記事の素材にする という方針で動き始めました。
移行前の構成と運用
移行前の構成は以下のとおりです。
| 役割 | サービス |
|---|---|
| ホスティング | S3(バケット名 tech-challenge-blog) |
| CDN | CloudFront ディストリビューション |
| TLS 証明書 | ACM(us-east-1) |
| DNS | Route 53(ホストゾーン cloud-and-code.com) |
| ドメインレジストラ | お名前.com(NS は Route 53 に向けていた) |
| デプロイ | ローカルの deploy.sh(手動実行) |
deploy.sh の中身は要約すると次のような処理です。
# 1) AWS SSO ログインの確認(未ログインなら login)
aws sts get-caller-identity --profile "$AWS_PROFILE" || aws sso login --profile "$AWS_PROFILE"
# 2) Next.js 静的ビルド
npm run build
# 3) S3 に同期(不要ファイルは除外)
aws s3 sync out/ "s3://${S3_BUCKET}/" --profile "$AWS_PROFILE" --delete --acl private \
--exclude "*.env*" --exclude ".claude/*" ...
# 4) CloudFront のキャッシュを無効化
aws cloudfront create-invalidation --distribution-id "$CF_DISTRIBUTION_ID" --paths "/*"
技術的には何の問題もありません。むしろ素直で、AWS の作法を学ぶには良い構成です。ただ「記事を1本公開する」というオペレーションのために毎回これを回すのは、運用が続くほどボディブローのように効いてきます。
なぜ Cloudflare Pages を選んだか
候補は複数ありました。比較したうえでの判断軸を残しておきます。
| 候補 | 強み | このブログでの懸念 |
|---|---|---|
| Cloudflare Pages | 帯域・リクエスト無料枠が太い、DNS と一体運用、エッジが日本含め多い | Workers モードと Pages モードの境目がややこしい(後述) |
| Vercel | Next.js 公式、設定が最も楽 | 個人プランの帯域上限・商用利用制約が将来悩みになりそう |
| Netlify | 老舗の安心感、リダイレクト周りの仕様が枯れている | 帯域上限がやや手狭 |
| GitHub Pages | 無料、リポジトリ完結 | カスタムドメインの SSL 周りや Next.js の static export との相性で多少手間 |
このブログは 記事+AWS/AI 関連ツール(クライアントサイドのみで完結する計算ツール群)で構成されており、サーバーサイドのレンダリングが要らない純粋な静的サイトです。今後アクセスが急増しても無料枠で耐えやすい Cloudflare Pages を選びました。
加えて、すでに Cloudflare Web Analytics は別記事 Cloudflare Web AnalyticsでNext.jsサイトのPVを無料で計測する方法【AWS + CloudFront構成】 で導入済みだったので、最低限のアカウントも用意できていた、というのも背中を押しました。
詰まったポイント 5 件
ここからが本記事の本題です。実際に手を動かしている間に踏んだ落とし穴を、症状・原因・解決の順で並べます。
詰まり1: 「Pages」のつもりが「Workers + OpenNext」として作成されてしまう
最初のビルドは、next build も postbuild も全部成功(OG 画像 109 件と sitemap 生成まで完走)したのにデプロイで落ちました。
ログの該当箇所はこんな具合です。
Detected Project Settings:
- Worker Name: my-learning-blog
- Framework: Next.js
- Build Command: npm run build
- Output Directory: .next ← 静的エクスポートなのに .next を期待
Executing user deploy command: npx wrangler deploy
...
[build] Bundling cache assets...
[build] node:fs:442
[build] Error: ENOENT: no such file or directory,
open '/opt/buildhome/repo/.next/standalone/.next/server/pages-manifest.json'
@opennextjs/cloudflare(OpenNext)で SSR Next.js 用 のビルドが走り、.next/standalone/... を探して見つからず ENOENT で落ちている、という構図でした。
原因: 現行の Cloudflare ダッシュボードでは「Workers & Pages」が統合された UI で、Connect to Git で Next.js リポジトリを選ぶと、フレームワーク自動検出が Workers + OpenNext モードでプロジェクトを作ってしまうことがあります。output: 'export' で純粋に静的サイトとして使いたい場合、これは想定外の挙動です。
解決: プロジェクトをいったん削除し、Pages として作り直しました。新規作成時のビルド設定で重要なのは次の 2 点です。
| 項目 | 値 |
|---|---|
| Framework preset | None(Next.js を選ばない) |
| Build output directory | out(.next ではない) |
Framework preset を None にしておけば、npm run build を素直に実行して out/ を静的アセットとして配信する、Pages の標準的な動作になります。「Next.js だから Next.js を選んだほうが親切だろう」と思って選ぶと罠にはまるパターンでした。
詰まり2: out/_not-found と out/404 が Search Console で問題化(再発)
旧構成(S3+CloudFront)でも同じ問題と戦った歴史があります。詳しくは別記事 Search Console「検出 - インデックス未登録」が減らない原因:noindexタグページがsitemap.xmlに残っていた話 を参照してください。
簡単に言うと、Next.js は静的エクスポート時に out/_not-found/index.html と out/404/index.html を吐きます。これらは中身に noindex メタを含むのですが、URL としては /_not-found/ /404/ で HTTP 200 を返してしまうため、Search Console から見ると「200 だが noindex の重複ページ」として除外レポートに大量に出てくるという現象が起きます。
旧構成では deploy.sh の中で次の 1 行で対処していました。
# Next.js が自動生成する noindex 付きフォールバックページを削除
rm -rf out/_not-found out/404
しかし Cloudflare Pages は deploy.sh を使わない(git push で Cloudflare 側がビルドする)ため、この処理が効かなくなり、移行直後に同じ問題が再発しました。
解決: 後始末を postbuild フェーズに組み込みました。
// scripts/post-export-cleanup.mjs
import fs from 'node:fs';
import path from 'node:path';
const outDir = path.resolve('out');
// out/404/index.html → out/404.html(Cloudflare Pages のカスタム 404 として認識される)
const src = path.join(outDir, '404', 'index.html');
const dst = path.join(outDir, '404.html');
if (fs.existsSync(src)) fs.renameSync(src, dst);
// 不要ディレクトリを削除
for (const target of [path.join(outDir, '404'), path.join(outDir, '_not-found')]) {
if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
}
// package.json
"postbuild": "node scripts/generate-og-images.mjs && node scripts/generate-sitemap.mjs && node scripts/post-export-cleanup.mjs"
ポイントは、out/404/index.html を out/404.html にリネーム している点です。Cloudflare Pages は out/ 直下に 404.html があると、それを「存在しないパスにアクセスされたときのフォールバック」として 404 ステータスで配信してくれる仕組みになっています。out/404/index.html(ディレクトリ + index.html)の形だとこの自動認識は働かないので、ファイル名形式に揃える必要がありました。
詰まり3: _redirects の通常ルールが効かない、!(force flag)が必要
詰まり2を解消したあと、こんどは /404(slash なし)が HTTP 200 で配信される問題が残りました。
$ curl -s -o /dev/null -w "%{http_code}\n" "https://my-learning-blog-36p.pages.dev/404"
200
out/404.html が静的アセットとして直接配信できる状態だったため、URL /404 への直接アクセスで 200 が返ってきていました。Search Console から見ると、これも「200 だが noindex の重複」として面倒な扱いを受けます。
最初に試したこと: public/_redirects でルールを書いてみる。
/404 /404.html 404
/_not-found /404.html 404
これで効くだろう、と思ったのですが、/404 は依然として 200 のまま でした。Cloudflare Pages の挙動として、静的アセット(out/404.html)と _redirects のルールがバッティングしたとき、静的アセットが優先されるケースがあるようです。
解決: ルールに !(force flag)をつけて、静的ファイルがあってもリダイレクトルールを必ず適用させるようにしました。
/404 /404.html 404!
/_not-found /404.html 404!
ただし、/404 への直接アクセスについては、これでも完全には 404 化できませんでした。Cloudflare Pages のカスタム 404 機能と密に結合しているせいか、_redirects の force flag でも落とし切れないケースがあります。
最終的な落としどころは次のとおりです。
robots.txtでDisallow: /404Disallow: /_not-foundを明示済み(クローラーはそもそも来ない)- HTML 自体に
<meta name="robots" content="noindex">が入っているのも引き続き有効 - 「200 が返ること」自体は許容するが、SEO 影響は実質ゼロ
完璧ではないが、Search Console の除外レポートに大量に出てくる主因は片付いた、というラインで合意しました。
詰まり4: ビルトイン 404 と layout.js の metadata が重複出力されていた(実は最大の収穫)
詰まり 3 まで対処したあと、念のため /404 の HTML 中身を眺めていて気づきました。
<title>404: This page could not be found.</title>
<meta name="robots" content="noindex"/>
<title>Tech Blog - AWSツール & 技術ブログ</title>
<meta name="robots" content="index, follow"/>
<title> が 2 個、しかも <meta robots> は noindex と index, follow が同居 している、という由々しき状態でした。
Google のドキュメントでは「複数の robots ディレクティブが指定された場合、最も制限の強いものが優先される」とされてはいるものの、こんな矛盾だらけの HTML がインデックス除外の判定に良い影響を与えるはずがありません。Search Console の過去の「200 で重複ページ + noindex」の真因はこれだったのでは、と推測しました。
原因: このプロジェクトには app/not-found.js を作っていなかったため、Next.js の ビルトインの 404 ページ が使われていました。ビルトイン 404 は自前で <title> と <meta robots="noindex"> を出力します。一方で、app/layout.js のグローバル metadata(<title> テンプレート、<meta robots="index, follow"> など)も同じページに乗ってきて、結果として 2 重出力になっていました。
解決: app/not-found.js を新規作成し、metadata を 404 専用に明示オーバーライド。
// app/not-found.js
import Link from 'next/link';
export const metadata = {
title: 'ページが見つかりません',
description: 'お探しのページは存在しないか、移動した可能性があります。',
robots: {
index: false,
follow: false,
googleBot: { index: false, follow: false },
},
// layout 側の canonical (`/`) を継承させない
alternates: { canonical: null },
openGraph: {
title: 'ページが見つかりません',
description: 'お探しのページは存在しないか、移動した可能性があります。',
images: [],
},
};
export default function NotFound() {
return (
<div className="not-found">
<h1>404 - ページが見つかりません</h1>
<p>お探しのページは存在しないか、移動した可能性があります。</p>
<p><Link href="/">トップページに戻る</Link></p>
</div>
);
}
修正後のビルド出力は次のようになりました。
<title>ページが見つかりません - Tech Blog</title>
<meta name="robots" content="noindex"/>
<meta name="robots" content="noindex, nofollow"/>
<meta name="googlebot" content="noindex, nofollow"/>
<title> は 1 個だけ、<meta robots> は依然 2 つあるものの 両方 noindex 系 で内容に矛盾がなくなりました。canonical も layout の / が継承されない(alternates.canonical: null で明示無効化)ので、404 ページが「/ の重複」と見なされる原因も消えました。
これは Cloudflare 移行とは独立した既存バグ だったので、移行のおかげで掘り起こせた、という意味では一番大きな副産物だったかもしれません。
詰まり5: NS 切替直後、本番 HTTPS が SSL handshake failure
NS(ネームサーバー)の切替を実施した直後、本番ドメインの https://cloud-and-code.com/ へアクセスすると、こんなエラーになりました。
$ curl -v "https://cloud-and-code.com/" --max-time 10
* Connected to cloud-and-code.com (104.21.81.157) port 443
* (304) (OUT), TLS handshake, Client hello (1):
* error:1404B410:SSL routines:ST_CONNECT:sslv3 alert handshake failure
curl: (35) ... handshake failure
DNS 解決自体は Cloudflare のプロキシ IP(104.21.x / 172.67.x)を返しており、HTTP(80 番)は CloudFront にフォールバックして動いていましたが、HTTPS だけが TLS ハンドシェイクで弾かれている状態です。
原因: Cloudflare の Universal SSL 証明書がまだ発行されていない時間帯でした。NS 切替直後はゾーンの所有権確認 → 証明書発行のフェーズで、数分〜数十分かかる場合があります。
選択肢の検討:
- A. Universal SSL の発行を 待つだけ で復旧する
- B. A レコードのプロキシ雲を一時的に オフ(DNS only / グレー雲)にして、CloudFront に直結させる
- C. Phase を前倒し して、本番ドメインを Pages に直接割り当てる
実際にはどれを選んだかというと、検討している間に A の待機 で自然復旧しました。ダッシュボード上のメッセージ通り、本番切替の操作を続けながら様子見しているうちに、Universal SSL が Active になって HTTPS が通るようになりました。
教訓: NS 切替の時間帯はクローラーやモニタリングの影響を意識しておく。サイトの規模次第で、HTTPS が一時的にダウンしている時間が許容できないなら B または C を選択するのが良さそうです。
実際の移行手順(フェーズ別)
詰まりポイントを片付けながら、実際にやった手順は以下のとおりです。
Phase 0: GitHub にコードを push できる状態にする
Cloudflare Pages は GitHub 連携の自動ビルド を前提に組み立てるため、まずブログのリポジトリを GitHub(プライベート可)に上げておきます。すでに GitHub にコードがある場合はこのフェーズはスキップで OK です。
Phase 1: Cloudflare Pages プロジェクト作成
- Cloudflare ダッシュボード → Workers & Pages → Create → Pages タブを必ず選択
- Connect to Git で個人リポジトリを連携(
Only select repositoriesで対象だけ許可) - ビルド設定:
- Framework preset:
None(Next.js を選ばない) - Build command:
npm run build - Build output directory:
out - 環境変数:
NODE_VERSION=20
- Framework preset:
- Save and Deploy
ここで詰まり 1 を踏むと OpenNext モードに引きずり込まれて 30 分は溶けるので、最初から None を選ぶのが無難です。
Phase 2: プレビュー検証
<project>.pages.dev(Cloudflare 自動 URL)で全主要パスをcurl確認- Playwright か手動ブラウザで実描画チェック
- 詰まり 2〜4 をここで踏むので、
scripts/post-export-cleanup.mjsの追加・public/_redirectsの追加・app/not-found.jsの追加をこのフェーズで完了させる
Phase 3: DNS の Cloudflare 移管 + 本番カスタムドメイン切替
- Cloudflare に
cloud-and-code.comゾーンを追加 → 既存 DNS レコードが自動取り込みされる(要漏れチェック) - お名前.com 側のネームサーバーを Cloudflare の NS に変更
- NS 切替を
dig NS cloud-and-code.com +shortで確認(数分〜数十分) - Cloudflare Pages の Custom domains に
preview.cloud-and-code.comを追加 → プレビュー検証 - 本番
cloud-and-code.comとwww.cloud-and-code.comを Pages の Custom domains に追加- 既存 A レコード(旧 CloudFront 向き)との衝突は Override existing DNS records で上書き
- 切替完了後、
curl -sIでヘッダーを確認
移行後の Before/After
curl -sI で取れるヘッダーが、移行前後でこれだけ変わりました。
移行前(Cloudflare DNS 経由 → CloudFront → S3)
HTTP/2 200
server: cloudflare
x-cache: Hit from cloudfront
via: 1.1 ffe9646b2ea911744e2d51fc0715cedc.cloudfront.net (CloudFront)
x-amz-cf-pop: SEA900-P10
x-amz-cf-id: ...
cf-ray: 9f99f6836996d75a-NRT
server: cloudflare の手前に CloudFront のヘッダー(via、x-amz-cf-*)が並ぶ 多段経路でした。
移行後(Cloudflare Pages 直接配信)
HTTP/2 200
server: cloudflare
cf-ray: 9f9a0613bb5ad54d-NRT
content-type: text/html; charset=utf-8
CloudFront 由来のヘッダーが完全に消え、server: cloudflare のみ の 1 段経路になりました。経路が短くなった分、TTFB は若干良くなった印象があります(厳密な計測は別途 Phase 4 完了後にやる予定)。
運用面の差分
| 項目 | 移行前 | 移行後 |
|---|---|---|
| デプロイ方法 | bash deploy.sh(SSO ログイン → ビルド → S3 sync → invalidation) |
git push だけ |
| デプロイ時間(記事 1 本公開) | 体感 1〜2 分(ローカル環境依存) | 体感 1〜2 分(Cloudflare 側で完結) |
| 月額コスト | 数百円〜(CloudFront リクエスト + S3 ストレージ + Route 53) | $0(無料枠内) |
| TLS 証明書 | ACM(手動連携) | Universal SSL(自動) |
| ロールバック | S3 のバージョニング or 手動再 deploy | Cloudflare Pages のデプロイ履歴から 1 クリック |
「git push で本番反映」は当たり前と言えば当たり前ですが、CI を組まずにこれを成立させられるのは、個人ブログ運用としては素直に楽です。
旧構成の停止について(執筆時点ではまだ残してある)
本記事を書いている時点では、CloudFront ディストリビューションと S3 バケットはまだ生かしてあります。理由は次の 3 点です。
- DNS キャッシュの完全消滅まで数日かかる場合がある ので、まだ旧 CloudFront に来るリクエストがゼロとは言い切れない
- 移行直後に予想外の問題(一部ユーザーのみ表示崩れ等)が出た場合の 緊急ロールバック手段を残す
- 完全に廃棄するとアクセスログ・請求金額の Before/After を後追いで取れなくなる
1〜2 週間ほどモニタリングしてから、CloudFront 無効化 → S3 バックアップ → ACM/Route 53 削除という順で、Phase 4 として旧構成を順次停止する予定です。停止作業の記録もまた別記事にする予定です。
どんな人に Cloudflare Pages を勧めるか
僕個人の感覚では、次のような人にはかなり強くおすすめできます。
- 個人ブログ・ポートフォリオ・小規模ドキュメントサイトを運用したい
- ホスティングは静的アセットで完結(SSR が不要、もしくは別サービスに切り出せる)
- AWS の知識を活用しつつも、運用は身軽にしたい
- Web Analytics / DNS / WAF などを 1 アカウントに集約したい
逆に、次のような場合は素直に Vercel か AWS(あるいは別の選択肢)を検討したほうが良いです。
- Next.js の SSR / ISR を本格的に使う(OpenNext + Cloudflare Workers でやれる場合もあるが、罠が多い)
- AWS のサービス連携(S3 直結、Cognito 認証、Lambda@Edge など)を前提に組んでいる
- 顧客向けのサービスで、SLA や帯域保証の観点で別契約が必要
「個人ブログをそろそろ移したいけど、移行のたびに罠を踏みたくない」というだけの目的なら、本記事の詰まりポイント 6 件を先回りで意識しておくと、半日〜1 日で完了できる作業量だと思います。
まとめ
- このブログを S3+CloudFront から Cloudflare Pages に移行した
- 詰まったポイントは 5 件、最大の収穫は Next.js のビルトイン 404 と
layout.jsの metadata 重複を発見できたこと - HTTP ヘッダー上は
via: cloudfront.net系が消え、配信経路が 3 段から 1 段 に短縮 - 月額コストは数百円〜から $0(無料枠内)に
git pushでビルド・デプロイが完結し、運用が大幅に身軽になった- 旧 AWS 構成は 当面残し、Phase 4 として 1〜2 週間後に順次停止予定
「AWS 学習の出発点として S3+CloudFront でブログを構築する」のは今でも有効な選択肢です(自分も最初の構築でかなり学べました)。一方で、学習は AWS、運用は別の身軽な場所 という分業も、個人ブログレベルでは十分アリだと感じています。
関連記事: