
Payload CMSをCloudflare Workers/D1/R2で動かす
Payload CMSをCloudflare Workers/D1/R2で動かす
Payload CMSの本体はNext.jsアプリだ。管理画面、REST/GraphQL API、コンテンツDBの操作、ファイルアップロード、認証、権限制御まで、同じプロジェクトのコードに入っている。
デプロイ先の候補はVercel、Node.jsサーバ、Cloudflare Workersなど。Next.jsアプリ本体をどこで動かすかを選ぶ話になる。Cloudflare構成では、Next.jsをOpenNextでWorkers向けにビルドする。コンテンツDBはD1、メディアはR2、管理画面の入口はCloudflare Access、bindingとdeployはWranglerで組み立てる。
各部品をCloudflareのどこにデプロイするか
| 担当 | Cloudflare側 |
|---|---|
| Next.js(Payload本体)のHTTPサーバを動かす | OpenNextでビルドしてWorkersで動かす |
| コンテンツDBのSQLを処理する | D1が受ける |
| 画像やPDFをアップロードする | R2に書き込む |
| 管理画面の入口で本人確認する | Cloudflare Accessで通す |
| 認証鍵や署名secretを注入する | Wrangler secretsとして渡す |
| bindingとdeployを定義する | wrangler.jsoncとwrangler deployで設定する |
| エラー・レイテンシ・利用量を観測する | Worker observability、D1 metrics、R2 metricsを見る |
縦に並べると、Payload側の仕事をCloudflare側のサービスが受け持つ関係が見える。
OpenNextは、Next.jsアプリケーションをCloudflare Workersで動く形へ変換するadapterだ。Payload v3はNext.jsで動く。Workersにデプロイするときは、Vercel向けでもNode.jsサーバ向けでもなく、OpenNextを通したビルドを使う。
Workers + OpenNextで動かす
ビルド成果物とdeploy
OpenNextでビルドした成果物をWranglerでWorkerにアップロードする。Workerの実体は次の設定とコマンドで定まる。
// wrangler.jsonc — Wranglerに「どのファイルをWorkerとして配るか」を教える
{
"main": ".open-next/worker.js" // OpenNextが出力するWorkerエントリ
}
# Next.jsをOpenNextでWorkers向けに変換し、.open-next/worker.jsを出力する
opennextjs-cloudflare build
# wrangler.jsoncを読み取り、Workerをアップロードする
wrangler deploy
bindingとは何か
bindingはWorkerからD1やR2といったCloudflareリソースを参照する名前付きのハンドルを指す。設定ファイルにbindingを書いておくと、Worker内のコードからenv.D1やenv.R2としてアクセスできる。
// wrangler.jsonc — WorkerからD1/R2を触るためのbindingを宣言する
{
"main": ".open-next/worker.js",
"d1_databases": [
// Worker内ではenv.D1で参照できる
{ "binding": "D1", "database_id": "xxxx-xxxx" }
],
"r2_buckets": [
// Worker内ではenv.R2で参照できる
{ "binding": "R2", "bucket_name": "my-media" }
]
}
binding値はWorkerが起動するときにCloudflareランタイムが注入する。
bindingを使うコードはどこに書く
OpenNextはビルド時にコードを静的に解析する。binding値はWorker実行時にしか与えられない。ビルド時に評価されるパス(モジュールのトップレベルやReact Server Componentの同期処理)でbindingを参照するとundefinedになり、ビルドが落ちる。bindingを使うコードは、route handlerの中やmigrationスクリプトといった実行時にだけ動くパスに置く。
NG(モジュールトップレベルでbindingを参照する)。
// app/page.tsx — NG: モジュールトップレベルでbindingに触る
import { getCloudflareContext } from '@opennextjs/cloudflare'
// 以下2行はimport時=ビルド時に実行されるため、Cloudflareランタイムが居ない。
// env.D1がundefinedになり、ビルドが落ちる。
const { env } = getCloudflareContext()
const rows = await env.D1.prepare('...').all()
export default function Page() {
return <PostList rows={rows} />
}
OK(リクエストを受けたhandler内で参照する)。
// app/api/posts/route.ts — OK: route handler内(実行時にだけ呼ばれる)
import { getCloudflareContext } from '@opennextjs/cloudflare'
export async function GET() {
// GET()はリクエストを受けた時点で実行される。この時点ではWorker内なのでbindingが渡っている。
const { env } = getCloudflareContext()
const rows = await env.D1.prepare('...').all()
return Response.json(rows)
}
SSGが必要なときは
Payloadが提供するのは管理画面、REST/GraphQL API、preview routeで、どれもリクエスト時に動く。Next.jsのSSGやgenerateStaticParamsの出番はほとんどない。
同じプロジェクト内に公開フロント((frontend) group routeなど)を同居させる場合や、どうしても静的化したい画面がある場合は、ビルド時にbindingが渡らないという制約に当たる。代替は2つある。
1つはISRとCloudflareの前段キャッシュで実質的に静的化する手だ。Workersの前段にCloudflareのCDNがある。レスポンスにCache-ControlやCDN-Cache-Controlヘッダを付けるとエッジでキャッシュされる。OpenNextにはR2やKVを裏に置くincremental cache adapterもある。revalidateやunstable_cacheがNext.jsと同じ感覚で使える。記事メディアのように同じレスポンスを再利用できるワークロードでは、これでSSGと同等の応答時間に到達する。
もう1つは完全SSGを別ジョブで作る手だ。ビルド時にCloudflare REST APIやD1 HTTP API経由で取得する。binding経由ではなく、APIトークン付きのfetchでビルド環境からD1を読む。CMS本番運用より、別ジョブで生成する静的サイトの構築に向く。
Edge Runtimeを宣言しない
Next.jsにはruntime = "edge"という宣言がある。書くとそのページやAPIはNext.js内蔵のEdge Runtime(V8ベースの軽量ランタイム)でビルドされる。本来はVercel Edge Functions向けの宣言だ。Cloudflare Workersも「エッジで動くV8環境」を提供するので、Cloudflareに出すならruntime = "edge"を書けばよさそうに見える。実際はこれが落とし穴だ。
OpenNextは、Next.jsの通常のNode.js runtime出力を解析してWorkers向けに変換する。Edge Runtime用のビルド出力は別形式で、OpenNextの変換対象には入らない。OpenNextのCloudflare構成では、アプリ側にexport const runtime = "edge"を残さない方針に揃える。app/配下のpage.tsxやroute.tsにこの行を書くと、OpenNextが期待する入力とずれてビルドが落ちる。原因の特定が難しいため、最初から書かない。
D1とR2をPayloadにつなぐ
Payload側はsqliteD1AdapterにD1 binding、r2StorageにR2 bucket bindingを渡す。先述のwrangler.jsoncで紐づけたD1とR2を、Payloadの設定ファイル側で受け取る。
// payload.config.ts — PayloadにCloudflare D1とR2を渡す
import { sqliteD1Adapter } from '@payloadcms/db-d1-sqlite'
import { r2Storage } from '@payloadcms/storage-r2'
import { buildConfig } from 'payload'
export default buildConfig({
collections: [Media, Pages, TechBlogs],
db: sqliteD1Adapter({
binding: cloudflare.env.D1, // wrangler.jsoncで宣言したD1 binding
push: false, // 起動時のスキーマ自動同期を切り、migration経由のみ許可する
}),
plugins: [
r2Storage({
bucket: cloudflare.env.R2, // wrangler.jsoncで宣言したR2 binding
collections: {
media: {
// 管理画面とAPIが返すメディアURLを組み立てる関数。
// Workerドメインではなく、R2に接続した独自ドメイン側のURLを返す。
generateFileURL: ({ filename, prefix }) => {
const base = process.env.NEXT_PUBLIC_MEDIA_URL // 例: https://media.example.com
const key = prefix ? `${prefix}/${filename}` : filename
return `${base?.replace(/\/+$/, '')}/${encodeURIComponent(key)}`
},
},
},
}),
],
})
push: falseは、Payloadの「起動時にコード上のコレクション定義をDBスキーマへ差分同期するモード」を切る設定だ。開発時はスキーマ変更が即反映されて便利だが、本番ではコード更新のたびに想定外のスキーマ変更が起動の瞬間に走る。データ消失や互換性の崩壊につながる。本番ではpush: falseを入れる。スキーマ変更はmigrationコマンドで明示的に流す(payload migrate:createで差分SQLを生成、payload migrateで適用)。git履歴に残るので追跡もできる。
generateFileURLはupload collectionが返すメディアの公開URLを組み立てる関数だ。何も書かないとPayloadのAPI経由のURLが返り、画像配信のたびにWorkerが間に入ってR2を読みに行く。Workers requestとCPU時間が画像トラフィック分だけ増える。R2にはbucketへ独自ドメインを接続する機能がある。そのドメインをgenerateFileURLが返すように書いておけば、クライアントは直接R2から取得する。Workersは間に入らない。
R2のr2.devエンドポイントは、bucketを有効化すると付くデフォルトの公開URLだ。検証用で、本番用途には使わない案内になっている。rate limitが低く、production用途には不向きだとdocs上で示されている。本番のメディア配信は独自ドメインを接続する。前段にCloudflareのCDN(エッジでのキャッシュレイヤー)とWAF(不正なリクエストを止めるWeb Application Firewall)が立つ構成になる。
なお、PayloadのD1 adapterとR2 storage adapterは、Payloadのドキュメント上でbetaとして案内されている。Payload本体・D1 adapter・R2 storage adapterのバージョン組み合わせを揃え、migrationと基本操作を一度通してから本番に載せる。
管理画面の入口はCloudflare Access
Payloadの管理画面のURLは公開Workerと同じドメインに出る。Worker側でログイン画面やIPアドレス制限を自前で作るより、前段にCloudflare Accessを置く方が省力だ。Accessは本人確認を済ませてからWorkerにリクエストを通す。
Cloudflare Access側の設定
Cloudflare AccessでSelf-hostedのApplicationを作る。対象URLには管理画面のパス(cms.example.com/admin*など)を指定する。Identity providerにはGoogle WorkspaceやEmail OTPなどを紐づける。Policyで「特定のメールアドレスや組織ドメインだけ通す」というルールを書いておく。
通過したリクエストには、CloudflareがCf-Access-Jwt-Assertionヘッダを付ける。中身はJWTで、認証済みユーザのemailなどがclaimとして入る。
Payload側の認証strategy
Payloadはコレクションに独自の認証strategyを追加できる。Worker内でCf-Access-Jwt-Assertionヘッダを読み、Cloudflareのteam domainが公開しているJWKで署名検証する。検証できたemailをusersコレクションのドキュメントに対応づける。
// payload.config.ts のusers collection — Cloudflare Access経由のログインを受ける
import type { CollectionConfig } from 'payload'
const Users: CollectionConfig = {
slug: 'users',
auth: {
strategies: [
{
name: 'cloudflare-access',
authenticate: async ({ payload, headers }) => {
// Cloudflare Accessが通過させたリクエストに付けるJWTを取り出す
const token = headers.get('Cf-Access-Jwt-Assertion')
if (!token) return { user: null }
// Cloudflareのteam domainが公開するJWKで署名を検証してclaimsを取り出す
const claims = await verifyAccessJwt(token, {
teamDomain: process.env.CF_ACCESS_TEAM_DOMAIN!,
audience: process.env.CF_ACCESS_AUD!,
})
// 検証できたemailを、事前登録された編集者ドキュメントへ対応づける
const found = await payload.find({
collection: 'users',
where: { email: { equals: claims.email } },
})
return { user: found.docs[0] ?? null }
},
},
],
},
fields: [{ name: 'email', type: 'email', required: true, unique: true }],
}
Payloadのusersコレクションには、各編集者のemailをあらかじめ登録しておく。Accessを通った編集者はそのまま管理画面へ入る。あとはPayloadのaccess controlで操作単位の制御が走る。
二段で持たせる責務
Accessは「管理画面のURLに誰が辿り着けるか」を制御する。Payloadのaccess controlは「ログイン後にどのコレクションのどの操作ができるか」を制御する。サービス用APIキーや編集者ロールを扱うときは両方を組み合わせる。
性能はD1 rows readとWorkers CPUの2軸
CMSが遅くなったときに見る指標は2つに分かれる。
Workers CPU時間
Workers CPU時間はNext.jsとPayloadのコードがWorker上で動いた時間だ。重いSSR、画像URLの組み立て、認証チェック、JSON整形などでWorkers CPUが伸びる。
D1 rows read
rows readは、D1がSQLの処理中に読み込んだ行数を意味する。返した行数ではなく、内部で何行読み込んだかを表す利用量だ。
WHERE句やソートで使う列にindexが無いと、D1は条件に合うかを1行ずつ確かめる。テーブル全体を読み込む(全件走査)。たとえば10万件のpostsテーブルからWHERE slug = 'foo'を引いたとき、slugにindexがあれば数行のreadで済む。無ければ10万行を読んで1件を返す。APIレスポンスが1件でもrows readは10万に跳ねる。
D1のクエリ処理量
D1は1つのデータベース内でクエリを順に処理する。並列で叩いても、同じD1の中ではキューに並ぶ。1クエリの処理時間(SQL duration)が短いほど、捌けるクエリ数は増える。
| SQL duration | 1つのD1データベースで見込む処理量の目安 |
|---|---|
| 1ms | 約1000クエリ/秒 |
| 10ms | 約100クエリ/秒 |
| 100ms | 約10クエリ/秒 |
CMSで詰まる典型は、indexのない一覧検索、深いrelation読み込み、公開APIでの過剰なフィールド返しだ。一覧でよく絞り込む更新日時、公開状態、slugにindexを張る。relationのdepthは抑え、公開APIのレスポンスから編集用フィールドを除く。画像やPDFはAPI経由ではなく、R2の独自ドメインから直接配信させる。
編集頻度が低い記事メディアでは、Workersのリクエスト数とD1のrows readは基本的に詰まらない。書き込みの多い業務アプリ、巨大な一覧検索、複雑な集計を同じD1に置く場合は別だ。1つのD1で順に処理する以上、ピーク時のクエリ数が処理上限を超えないかを設計時点で見ておく。
料金はWorkersとD1とR2で別の指標
Cloudflare Workers、D1、R2は、それぞれ別の指標で料金が決まる。Workersはrequest数とCPU時間。D1はrows read、rows written、保存容量。R2は保存容量、Class A操作(アップロード、削除、prefix list)、Class B操作(ダウンロード、HEAD)だ。
例えば月200万requestのCMSを想定する。
| サービス | 利用量 |
|---|---|
| Workers | CPU 1600万ms |
| D1 | rows read 2億行、rows written 200万行、保存2GB |
| R2 | 20GB-month、Class A操作20万回、Class B操作500万回 |
| 項目 | 月間利用量 | 追加料金の例 |
|---|---|---|
| Workers requests | 2,000,000 requests | $0.00 |
| Workers CPU | 16,000,000 ms | $0.00 |
| D1 rows read | 200,000,000 rows | $0.00 |
| D1 rows written | 2,000,000 rows | $0.00 |
| D1 storage | 2 GB | $0.00 |
| R2 Standard storage | 20 GB-month | $0.15 |
| R2 Class A operations | 200,000 requests | $0.00 |
| R2 Class B operations | 5,000,000 requests | $0.00 |
| Workers Paid base | – | $5.00 |
| 合計 | – | $5.15 |
このスケールで追加料金が乗るのはR2のストレージ分だけだ。伸びる指標で打つ手は変わる。API呼び出しならWorkers request、SSRや画像加工ならWorkers CPUを見る。一覧検索ならD1 rows read、サムネイル再生成やprefix listならR2 Class Aを見る。R2はインターネット向けのデータ転送料金がない点も、料金軸として読むうえでは前提になる。代わりに、操作回数(list、put、deleteなど)が積み上がる。
この構成のpros/cons
Cloudflareを選ぶ理由は、コストとパフォーマンスだ。R2のegress無料とWorkers Paidの広い無料枠でコストが抑えられ、Cloudflare CDNが前段に立ってレイテンシも下がる。
| 軸 | pros | cons |
|---|---|---|
| 料金 | R2のegressが無料、Workers Paidの無料枠内に中規模CMSが収まる | indexなしの絞り込みでD1 rows readが跳ねる |
| 性能 | Cloudflare CDNがWorkersの前段に立つ | D1は1DB内でクエリを順に処理する。Workers CPU時間にも上限がある |
| 構成統合 | bindingとdeployをwrangler.jsoncに集約できる | Next.jsの新機能はOpenNextの対応待ちになる |
| 認証 | Cloudflare Accessがログイン画面を代替する | Payload側の認証strategyを自前で書く |
| SSG/SSR | — | ビルド時にbindingが渡らず、generateStaticParamsでのSSGが成立しない。ISRや別ジョブで代替 |
| 成熟度 | — | D1/R2 adapterがbeta。Payload本体とバージョンを揃える |
向く用途
編集頻度がそれほど高くなく、画像やPDFを多く配信するメディアサイトや社内CMS、中小規模のオウンドメディアに向く。書き込み量が大きい業務システム、複雑な集計、重い変換処理を同居させたい構成では、別のスタックを検討したい。





