Tech BlogAWSツール & 技術ブログ

個人技術ブログを 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 buildpostbuild も全部成功(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 presetNone にしておけば、npm run build を素直に実行して out/ を静的アセットとして配信する、Pages の標準的な動作になります。「Next.js だから Next.js を選んだほうが親切だろう」と思って選ぶと罠にはまるパターンでした。

詰まり2: out/_not-foundout/404 が Search Console で問題化(再発)

旧構成(S3+CloudFront)でも同じ問題と戦った歴史があります。詳しくは別記事 Search Console「検出 - インデックス未登録」が減らない原因:noindexタグページがsitemap.xmlに残っていた話 を参照してください。

簡単に言うと、Next.js は静的エクスポート時に out/_not-found/index.htmlout/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.htmlout/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.txtDisallow: /404 Disallow: /_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ツール &amp; 技術ブログ</title>
<meta name="robots" content="index, follow"/>

<title> が 2 個、しかも <meta robots>noindexindex, 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 プロジェクト作成

  1. Cloudflare ダッシュボード → Workers & Pages → Create → Pages タブを必ず選択
  2. Connect to Git で個人リポジトリを連携(Only select repositories で対象だけ許可)
  3. ビルド設定:
    • Framework preset: NoneNext.js を選ばない
    • Build command: npm run build
    • Build output directory: out
    • 環境変数: NODE_VERSION=20
  4. Save and Deploy

ここで詰まり 1 を踏むと OpenNext モードに引きずり込まれて 30 分は溶けるので、最初から None を選ぶのが無難です。

Phase 2: プレビュー検証

  1. <project>.pages.dev(Cloudflare 自動 URL)で全主要パスを curl 確認
  2. Playwright か手動ブラウザで実描画チェック
  3. 詰まり 2〜4 をここで踏むので、scripts/post-export-cleanup.mjs の追加・public/_redirects の追加・app/not-found.js の追加をこのフェーズで完了させる

Phase 3: DNS の Cloudflare 移管 + 本番カスタムドメイン切替

  1. Cloudflare に cloud-and-code.com ゾーンを追加 → 既存 DNS レコードが自動取り込みされる(要漏れチェック)
  2. お名前.com 側のネームサーバーを Cloudflare の NS に変更
  3. NS 切替を dig NS cloud-and-code.com +short で確認(数分〜数十分)
  4. Cloudflare Pages の Custom domainspreview.cloud-and-code.com を追加 → プレビュー検証
  5. 本番 cloud-and-code.comwww.cloud-and-code.com を Pages の Custom domains に追加
    • 既存 A レコード(旧 CloudFront 向き)との衝突は Override existing DNS records で上書き
  6. 切替完了後、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 のヘッダー(viax-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 点です。

  1. DNS キャッシュの完全消滅まで数日かかる場合がある ので、まだ旧 CloudFront に来るリクエストがゼロとは言い切れない
  2. 移行直後に予想外の問題(一部ユーザーのみ表示崩れ等)が出た場合の 緊急ロールバック手段を残す
  3. 完全に廃棄するとアクセスログ・請求金額の 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、運用は別の身軽な場所 という分業も、個人ブログレベルでは十分アリだと感じています。

関連記事: