# 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のどこにデプロイするか

```mermaid
flowchart LR
  editor["編集者"]
  visitor["公開サイト / API利用者"]
  deploy["wrangler deploy"]

  subgraph build["ビルド"]
    nextapp["Payload + Next.js"]
    opennext["OpenNext で Workers 向けに変換"]
  end

  subgraph cloudflare["Cloudflare runtime"]
    access["Cloudflare Access"]
    workers["Workers（管理画面 + API）"]
    d1["D1（コンテンツ DB）"]
    r2["R2（メディア）"]
    secrets["Wrangler secrets"]
  end

  nextapp --> opennext --> workers
  editor --> access --> workers
  visitor --> workers
  workers --> d1
  workers --> r2
  secrets --> workers
  deploy --> workers
  deploy --> d1
```

| 担当 | 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の実体は次の設定とコマンドで定まる。

```jsonc
// wrangler.jsonc — Wranglerに「どのファイルをWorkerとして配るか」を教える
{
  "main": ".open-next/worker.js"  // OpenNextが出力するWorkerエントリ
}
```

```bash
# 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`としてアクセスできる。

```jsonc
// 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を参照する）。

```ts
// 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内で参照する）。

```ts
// 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の設定ファイル側で受け取る。

```ts
// 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として案内されている](https://payloadcms.com/docs/database/sqlite)。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コレクションのドキュメントに対応づける。

```ts
// 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、中小規模のオウンドメディアに向く。書き込み量が大きい業務システム、複雑な集計、重い変換処理を同居させたい構成では、別のスタックを検討したい。
