NotionブログをSSG化してCloud RunからCloudflare Pagesに移行しました
2026-01-30

この記事では、NotionをCMSにしたブログのインフラをGoogle CloudからCloudflare Pagesに移行した経緯や、移行に伴う課題、実施した具体的な手順、移行後の成果について詳しく解説します。
はじめに
この記事では、NotionをCMSにしたブログのインフラをGoogle Cloud(Cloud Run)からCloudflare Pagesに移行した話を書いていきます。
前編(Webhook × GCS編)では、Notion APIの画像URL有効期限問題に対してWebhook × GCSで永続化する方法を紹介しました。動くようにはなったんですが、運用してみるといろいろ課題が出てきて、結局アーキテクチャごと見直すことにしました。
個人ブログのインフラ移行を考えている人や、Notion CMSの画像問題に困っている人向けです。
前編方式の課題
前編で構築したWebhook × GCS方式、動くことは動くんですが、運用していくうちに3つの課題が見えてきました。
Webhookの信頼性が微妙
前編記事でも触れましたが、Notionのwebhookは発火条件が多すぎます。ちょっとした編集でも飛んできてしまうし、配信失敗や順序保証もありません。画像同期の基盤としては正直少し不安定でした。
コストが個人ブログにしては高い
Cloud Run + GCS + Cloud CDN + Cloud Buildで月額約2,800円。特にCloud CDNを使うために必要なロードバランサーの固定費(転送ルール1つで約$18/月)が大きくて、個人ブログのトラフィック量に対して明らかにオーバーでした。
構成が複雑すぎる
Notionで画像を更新 → Webhookが発火 → Cloud Runで受信 → Notionから画像取得 → GCSにアップロード → Cloud CDN経由で配信。と、パイプラインが長めです。
なぜSSG × Cloudflare Pagesなのか
そもそもの問題は「Notionの画像URLに有効期限がある」ことです。Webhook方式ではリアルタイムに画像を同期していましたが、冷静に考えると個人ブログにリアルタイム性は必要ありません。
ビルド時にNotionから画像をダウンロードして静的ファイルとして配信すれば、Webhookも GCSも不要になります。SSG(Static Site Generation)なら画像もHTMLも全部まとめて静的ファイルにできるので、ホスティングもシンプルです。
ホスティング先としてCloudflare Pagesを選んだのは、無料プランでも無制限のリクエスト・帯域幅が使えるからです。個人ブログならこれで十分すぎます。
構成の変化はこんな感じです:
Before: Notion → Cloud Run(ISR) → GCS + Webhook + Cloud CDN
After: Notion → GitHub Actions(ビルド時画像DL) → Cloudflare Pages
だいぶシンプルな構成となりました。
やったこと
1. Next.jsのSSG化
まずNext.jsの設定を変更しました。
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
};
output: "export" でSSGモードに切り替えて、images: { unoptimized: true } でNext.jsの画像最適化を無効化しています。SSGではサーバーサイドの画像最適化が使えないので、この設定が必要です。
あわせて、ISR用の revalidate 設定を削除し、dynamicParams = false を追加して、ビルド時に生成されないパスへのアクセスは404を返すようにしました。
2. ビルド時画像ダウンロードスクリプト
SSGではビルド時に画像を手元に持っている必要があるので、Notionから画像をダウンロードするスクリプトを作りました。
仕組みとしては、Notion APIで全公開記事を取得し、カバー画像とコンテンツ内の画像をダウンロードして public/images/posts/{slug}/ に保存します。画像はsharpでwebp形式に変換してファイルサイズを削減しています。
ポイントは .image-manifest.json による増分ダウンロードです。一度ダウンロードした画像はマニフェストに記録しておいて、次回ビルド時にはスキップします。記事が増えても毎回全画像をダウンロードする必要がないので、ビルド時間が無駄に伸びません。
3. TerraformでCloudflare Pages + DNS構築
インフラ側は既存のTerraform構造に、Cloudflare用のモジュールを追加しました。
- cloudflare_pages モジュール: Cloudflare Pagesプロジェクトの作成
- cloudflare_dns モジュール: Zone作成 + カスタムドメインのCNAME設定
GitHub連携はせず、GitHub Actionsからデプロイする構成にしています。DNS移管もこのタイミングでCloudflareに移しました。
4. GitHub Actionsでビルド & デプロイ
.github/workflows/deploy.yml を作成して、手動トリガー(workflow_dispatch)でビルドからデプロイまで自動化しました。
ポイントは画像のキャッシュです。actions/cache で public/images/posts/ と .image-manifest.json をキャッシュしておくことで、2回目以降のビルドでは新規・更新画像だけダウンロードすればよくなります。
デプロイは wrangler-action でCloudflare Pagesに nextjs/out ディレクトリ(SSGの出力先)をそのまま上げるだけです。
5. GCPリソースの全削除
Cloudflare Pagesでの動作確認とDNS切り替えが完了した後、不要になったGCPリソースを全部削除しました。
- Cloud Run(joifup-blog-nextjs)
- GCS(joifup-blog-assets)
- Artifact Registry(nextjs-repo)
- Cloud Build Trigger
- Service Accounts
Terraform側はGCPモジュールの呼び出しを削除し、関連ファイル(cloudbuild.yaml、docker/、docker-compose.yml)もリポジトリから消しました。GCPプロジェクト自体も削除して完全にクリーンアップしています。
アプリ側もWebhookエンドポイント(api/webhooks/notion-images/)やGCSアップロード処理(lib/gcs/)、@google-cloud/storage パッケージを削除しました。
Claude Codeで短時間移行できた話
今回の移行、実はほぼ全部Claude Codeに実装させました。Terraformのモジュール作成、画像ダウンロードスクリプト、GitHub Actionsのワークフロー、不要コードの削除まで、一通り任せています。
正直、ここまでスムーズにいくとは思っていませんでした。手戻りがほぼなく、短時間で移行が完了しています。
ちなみに前編のWebhook × GCS方式は手動で2日くらいかけて構築していて、エラー対処やら権限修正やらでfixコミットが大量に出ていました。今回はClaude Codeに任せた結果、SSG設定からCloudflareデプロイ、GCPリソース削除まで一気に終わっています。AI駆動開発の恩恵をかなり感じた体験でした。
移行の成果
コスト: 月額約2,800円 → 0円
Cloudflare Pagesの無料プランは月500ビルド・無制限リクエスト・無制限帯域幅なので、個人ブログなら完全に無料で運用できます。
構成のシンプル化
Webhook、GCS、Cloud CDN、Cloud Build、Cloud Runが全部なくなりました。「Notionに書く → GitHub Actionsでビルド → Cloudflare Pagesで配信」だけです。何かあったときにデバッグする箇所も激減しました。
静的配信によるパフォーマンス向上
SSGで事前にHTMLが生成されているので、サーバーサイドの処理が一切ありません。Cloudflareのエッジから直接配信されるので、表示速度もかなり速くなりました。
まとめ
- 前編のWebhook × GCS方式は動くけど複雑でコストもかかる構成でした
- SSG化してCloudflare Pagesに移行したら、月額0円でシンプルな構成になりました
- Claude Codeに実装を任せたら、短時間で手戻りなく移行が完了しました
個人ブログはシンプルな構成が正義だなと改めて感じました。最初からこうすればよかった感はありますが、Webhook × GCSで試行錯誤した経験があったからこそ「SSGでいい」という判断ができたので、回り道も無駄ではなかったと思います。
今後は記事の数を増やしていくことに注力したいです。