Guide/guides

Bun で作ったフルスタックアプリを Cloudflare Workers にデプロイする

Guren のアプリは Bun で開発し、同じコードを Cloudflare Workers にデプロイできます。データベースには D1、ファイルの保存には R2 を使います。

Bun で作ったフルスタックアプリを Cloudflare Workers にデプロイする

Guren のアプリは Bun で開発し、同じコードを Cloudflare Workers にデプロイできます。データベースには D1、ファイルの保存には R2 を使います。

@guren/plugin-cloudflare は、コントローラー、モデル、Inertia と React のページからワーカーをビルドし、静的アセットと D1 のマイグレーションもその横にそろえます。このガイドでは、まっさらなアカウントからデプロイが終わるまでをひと通り説明します。いま読んでいる guren.dev も、この方法でデプロイした Guren のアプリです。

Workers は、常駐するサーバーとは性質の違うランタイムです。ファイルシステムがなく、リクエストの間でメモリを共有せず、CPU 時間はミリ秒単位で制限されています。このガイドの大半は、その違いのためにアプリの設定をどこで変える必要があるかの説明で、対応は次のとおりです。

アプリの中 Workers での置き場所 説明
データベース D1 データベース(D1)
セッションと OAuth state D1 のテーブル セッションと OAuth state
ファイルの保存 R2(R2Driver 経由) ストレージ(R2)
APP_KEY などのシークレット wrangler secret put シークレット
public/ Workers Static Assets 静的アセット
lang/ ビルド時にワーカーへ埋め込む 翻訳
createScheduler() で登録したタスク Cron Triggers スケジュールタスク
永続エージェント Durable Objects 永続エージェント

インストール

bunx guren plugin @guren/plugin-cloudflare
bun add @guren/plugin-cloudflare

プラグインを入れると cloudflare:build コマンドが登録され、最初のビルドで wrangler.jsonc が生成されます。

ビルドとデプロイ

bunx guren cloudflare:build
bunx wrangler deploy

cloudflare:build はアプリの build スクリプトを実行したあと、ワーカーのエントリポイント、静的アセット、1 つのディレクトリにまとめたマイグレーションを収めた .cloudflare/ ディレクトリを組み立てます。古いワーカーを誤ってデプロイしないように、2 つの手順を 1 つのスクリプトにまとめておいてください。

{
  "scripts": {
    "cloudflare:build": "bun run build && bunx guren cloudflare:build --skip-app-build",
    "deploy:cloudflare": "bun run cloudflare:build && bunx wrangler deploy"
  }
}

rule

.cloudflare/ は生成物です。.gitignore に追加し、デプロイの前に毎回作り直してください。ほかのどこからも参照されないので、中身が古くなっていても気づかないまま古いコードをデプロイしてしまいます。

アプリをビルドする前に、guren doctor と同じデプロイランタイムのチェックが走ります。セッションや OAuth state をプロセスのメモリに置いている、Bun でしか読めないパスワードハッシャーを選んでいる、プロバイダをファイルシステムから探している、のどれかに当てはまると警告が出ます(ビルド自体は止まりません)。いずれもローカルでは動くのに Workers では動かないものです。警告は Vite の出力のあとではなく、まだ読んでいるうちに目に入る位置に出ます。

このチェックは、Workers で動くときのアプリを調べます。ビルドがアプリを introspect している間は @guren/plugin-cloudflare/env の isWorkersRuntime() が true を返すので、これで分岐する設定は Workers 側の分岐(SQLite ではなく D1)で判定されます。guren doctor と guren check は、これまでどおり bun run dev が通る分岐を読みます。アプリの登録中にクロージャを介さずバインディングを読んでいると、この introspection が失敗し、チェックは確かめられなかった項目として報告します。

ビルドは、トップレベルか、いずれかの env.<name> ブロックで "keep_names": false を設定した wrangler.jsonc も受け付けません。Guren は、デプロイをまたいで残るレコードにクラス名を書き込むからです。キューに入れたジョブとイベント、保存した通知は、static jobName・static eventName・type ゲッターで名前を固定していなければ、クラス名をキーにして保存されます。永続エージェントもクラス名で探されます。

wrangler は、keep_names で無効にしない限り esbuild の keepNames を有効にしてバンドルするので、"minify": true を指定するだけならこれらの名前は保たれます。keep_names を無効にすると、minify したときにすべてのクラス名が変わります。minify しなくても、同じ名前のトップレベルクラスが 2 つあると 2 つ目の名前が変わります(OrderShipped2)。そうなると、前回のデプロイで書き込んだレコードを解決できなくなります。

データベース(D1)

生成される wrangler.jsonc では、データベースを名前で指定していて、database_id は書かれていません。

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-app",
      "migrations_dir": ".cloudflare/d1-migrations"
    }
  ]
}

ID のないバインディングに対して、wrangler deploy(Wrangler 4.129 以降)は database_name の名前のデータベースを探し、なければ作成します。2 回目以降のデプロイでは、デプロイ済みのワーカーに結び付いているデータベースをそのまま使うので、ID をどこかに写す必要はありません。名前での検索はアカウント全体が対象なので、別のプロジェクトにすでに my-app という名前のデータベースがあると、デプロイはそれに結び付きます。database_name には、アカウントの中で重ならない名前を付けてください。Wrangler 4.151 以降は、ターミナルからデプロイしたときに作成した ID を wrangler.jsonc に書き戻します。CI からのデプロイではファイルを変えず、同じ探し方で同じデータベースに当たります。

自分で作ったデータベースを使う場合は、bunx wrangler d1 create my-app が出力した ID を "database_id" に書いてください。仮の値を書いたままにはしないでください。Wrangler は ID が書かれたバインディングを作成の対象から外すので、仮の値のままだとデプロイが失敗します。古いプラグインで生成したアプリには "database_id": "TODO: wrangler d1 create" が入っています。16 進数でない ID やすべて 0 の ID が残っていると、キーを削除するまで、ビルドがアプリのビルドの前に警告します。

config/database.ts で、ランタイムに応じてドライバを切り替えます。D1 は SQLite 互換なので、スキーマは SQLite のダイアレクトで書き、開発中はローカルの SQLite ファイルを使います。

// config/database.ts
import { createD1Database, createSqliteDatabase, defineDatabaseConfig } from '@guren/core'
import { isWorkersRuntime } from '@guren/plugin-cloudflare/env'
import env from './env.js'

const database = isWorkersRuntime()
  ? createD1Database({
      binding: 'DB',
      migrationsFolder: new URL('../db/migrations', import.meta.url),
    })
  : createSqliteDatabase({
      migrationsFolder: new URL('../db/migrations', import.meta.url),
      seedersFolder: new URL('../db/seeders', import.meta.url),
      // `context` はアプリの検証済み環境変数。`guren db:*` はアプリの外で動くので、スキーマを自分で解析する
      filename: (context) => (context?.env ?? env.parse(undefined, { mode: 'report' }).values).SQLITE_DATABASE_PATH,
    })

export const { getDatabase, migrateDatabase, closeDatabase, configureOrm, seedDatabase } = database

export default defineDatabaseConfig(database, {
  seedOnBoot: process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test',
})

config/env.ts に SQLITE_DATABASE_PATH: Env.string().default('./data/guren.db') を宣言し、database を createApp({ config }) に加えてください。リゾルバについては 設定ガイド で詳しく説明しています。

isWorkersRuntime は @guren/plugin-cloudflare/env から import してください。パッケージのルートにはビルド用のツールも入っていて、それはワーカーのバンドルには要らないからです。

binding にはバインディングそのものではなく、その名前を渡します。バインディングはリクエストが届いて初めて存在するので、ファクトリは最初のクエリのときに Workers の env から DB を読みます。名前で渡すと、Workers 側の分岐で introspect したとき(guren introspect --target cloudflare --json)に、どのバインディングを使っているかが報告されます。以前の書き方である () => getWorkersEnv<Env>().DB のような関数も動きますが、その場合は関数の外から名前が見えません。

マイグレーションの適用

Workers 上のアプリは自分でマイグレーションを実行しないので、マイグレーションはアプリの外から適用します。

bunx guren cloudflare:build          # .cloudflare/d1-migrations を再生成
bunx wrangler d1 migrations apply my-app --remote

初回のデプロイでは、wrangler deploy が作成するまでデータベースが存在しません。そのため、ビルド、デプロイ、マイグレーションの順に実行します。

bunx guren cloudflare:build
bunx wrangler deploy
bunx wrangler d1 migrations apply my-app --remote

最後のコマンドが終わるまでは、新しいワーカーが空のデータベースに対して公開されていて、テーブルを読むリクエストは失敗します。3 つを続けて実行するか、先に wrangler d1 create でデータベースを作り、最初のデプロイの前にマイグレーションを適用してください。

wrangler はフォルダにあるものをそのまま適用するので、この時点では Guren の破壊的マイグレーションのガードが働きません。bunx guren check(または bunx guren gate)を使うと、ブランチで追加したマイグレーションをマージ前に判定し、-- guren:allow-destructive のコメントがない DROP、型を狭める変更、テーブルの再作成を報告します。ガードの役目を果たすのはプルリクエストなので、その CI で全履歴を取得したうえで(fetch-depth: 0)bunx guren check --ci を実行し、通ったものだけをマージしてください。main の上で実行すると、コミット済みのマイグレーションはすべてリリース済みとみなされて何も判定されません。そのため、--remote に適用する直前に main で実行しても、wrangler がこれから適用するものの確認にはなりません。報告の内容は破壊的マイグレーションにあります。D1 はマイグレーション中も外部キーを有効にしたままなので、ほかのテーブルが ON DELETE CASCADE で参照するテーブルの再作成は、すべてのカラムをコピーしていてもここで報告されます。

rule

必ず先にビルドしてください。migrations_dir は生成ディレクトリの中を指しているので、ビルドする前は空です。wrangler は空のフォルダを見つけると「適用するマイグレーションはありません」と報告し、エラーにならずに正常終了します。ビルド前に適用すると、失敗しているのに成功したように見えます。

ワーカーが自分でシードを実行することもありません。defineDatabaseConfig がシードするのは、seedOnBoot が true で、しかもデータベースがマイグレーションありと報告したときだけです。cloudflare:build は NODE_ENV を production に固定しますし、D1 のハンドルには調べるファイルシステムがないので、マイグレーションなしと報告します。ローカルの bun run dev は NODE_ENV=development を設定するので、SQLite 側が使われ、マイグレーションがあればブート時にシードします。

1 つのコマンドでデプロイする(wrangler.jsonc)

bunx guren cloudflare:deploy を使うと、ここまでの手順を、空のデータベースで公開される時間ができない順に実行できます。ビルドし、D1 のデータベース(と OAUTH_KV)がなければ作り、未適用のマイグレーションを破壊的マイグレーションのガードにかけてから適用し、最後にデプロイします。

bunx guren cloudflare:deploy --plan   # 何をするかを表示するだけで、何も書き込まない
bunx guren cloudflare:deploy

wrangler.jsonc は読むだけで、書き換えません。解決した ID は、設定のコピーである .cloudflare/wrangler.deploy.json と .cloudflare/guren-worker.json に書き込み、デプロイは wrangler deploy -c でこのコピーを使います。どちらのファイルも、ビルドが .cloudflare/ の中に書く .gitignore に含まれます。計画、その確認項目、フラグは、cf CLI についてデプロイの計画と1 つのコマンドでデプロイするで説明しているものと同じです。wrangler.jsonc で違うのは次の点です。

  • 事前確認には、アプリの Wrangler が 4.129 以上であること、PATH 上の node がその Wrangler の求めるバージョンであること、Wrangler がログイン済みであること(bunx wrangler login、CI では CLOUDFLARE_API_TOKEN)が必要です。
  • D1 のデータベースは database_name の名前で探します。マイグレーションは、migrations_dir が .cloudflare/d1-migrations のバインディング(雛形の DB)に、平坦化したコピーとして適用します。cf でデプロイしたことがあり、d1_migrations に入れ子の名前(<folder>/migration.sql)が記録されているデータベースは、tracker-layout で止まります。Wrangler で適用すると、履歴がすべてもう一度実行されてしまうからです。
  • デプロイするのは wrangler.jsonc の最上位だけで、env.<name> のブロックは対象外です。
  • Wrangler はバケットの一覧を JSON で出せないので、R2 のバケットは一覧も作成もしません。ディスクが使う R2 バインディングが r2_buckets にないときは、追加するエントリを添えて resource-missing で止めます。r2_buckets にあってもアカウントにバケットがなければ、アップロードが失敗します。
  • 表示する後始末とロールバックのコマンドは Wrangler のもの(wrangler d1 delete、wrangler kv namespace delete、wrangler d1 time-travel restore、wrangler rollback)です。

セッションと OAuth state はデータベースに保存する

これは好みで選べることではありません。リクエストはそのたびに別の isolate に届く可能性があり、isolate 同士で共有できるのはデータベースだけだからです。メモリの実装のままでもローカルでは動いているように見えますが、本番ではセッションが毎回消えてしまいます。

// config/session.ts
import { defineSessionConfig } from '@guren/core'
import { sessions } from '../db/schema.js'

export default defineSessionConfig((env) => ({
  default: env.SESSION_DRIVER,
  stores: {
    database: { driver: 'database', table: sessions },
  },
}))

config/env.ts には SESSION_DRIVER: Env.string().default('database') を宣言しておきます。こうすれば、デプロイ先で何も設定しなくてもデータベースのストアが使われます。

OAuth も同じです。認可画面へのリダイレクトと、そこから戻ってくるコールバックは別々の isolate に届くのが普通なので、2 つを結びつける state は共有できる場所に置かなければなりません。

// config/oauth.ts
import { DatabaseOAuthStateStore, defineOAuthConfig } from '@guren/core'
import { oauthStates } from '../db/schema.js'

export default defineOAuthConfig(() => ({
  // `providers` は OAuth ガイドのとおり
  stateStore: new DatabaseOAuthStateStore(oauthStates),
}))

2 つの定義は、それを使うセッションミドルウェアの設定と一緒に createApp() に並べます。

// src/app.ts
import { createApp } from '@guren/core'
import database from '../config/database.js'
import env from '../config/env.js'
import oauth from '../config/oauth.js'
import session from '../config/session.js'
import { registerWebRoutes } from '../routes/web.js'

const app = createApp({
  env,
  config: [database, session, oauth],
  auth: {
    autoSession: true,
    sessionOptions: { cookieSecure: true },
  },
  routes: registerWebRoutes,
})

セッションと OAuth state のストアをサービスプロバイダで設定しているアプリも、そのまま動きます。サービスプロバイダを使うアプリ を参照してください。

どちらのストアにもテーブルが必要です。sessions は bunx guren add session で生成でき、oauth_states のカラムは Stateストレージ に載っています。bunx guren make:auth --oauth を使えば、両方のテーブルの生成と両方のストアの組み込みがまとめて済みます。

ストレージ(R2)

Workers にはファイルシステムがないので、local ストレージドライバは動きません。代わりに R2Driver を使うと、Cloudflare R2 のバケットをバケットバインディング経由で、StorageManager の同じ API から扱えます。用意する資格情報はなく、AWS SDK もバンドルに入りません。

バケットを作ってバインドします。

bunx wrangler r2 bucket create my-app-media
// wrangler.jsonc
"r2_buckets": [
  { "binding": "MEDIA", "bucket_name": "my-app-media" }
]

次に、config/storage.ts で media を既定のディスクにします。

// config/storage.ts
import { defineStorageConfig } from '@guren/core'

export default defineStorageConfig(() => ({ default: 'media' }))

ストレージの設定で指定できるドライバは、組み込みの local・s3・memory だけです。そのため、ディスク自体はプロバイダの register() の中で、バインド済みのマネージャに登録します。Workers 上では R2 を、それ以外ではローカルのファイルシステムを使います。ランタイムの判定は、config/database.ts が D1 の切り替えに使っているものと同じです。

// app/Providers/MediaDiskProvider.ts
import { ServiceProvider, LocalStorageDriver } from '@guren/core'
import { r2Disk } from '@guren/plugin-cloudflare'
import { isWorkersRuntime } from '@guren/plugin-cloudflare/env'

export default class MediaDiskProvider extends ServiceProvider {
  register(): void {
    const storage = this.container.make('storage')
    if (isWorkersRuntime()) {
      storage.registerDisk('media', ...r2Disk({ binding: 'MEDIA', publicUrl: 'https://media.example.com' }))
    } else {
      storage.registerDisk('media', () => new LocalStorageDriver({ root: './storage/app/public', url: '/storage' }))
    }
  }
}

storage を createApp({ config }) に、MediaDiskProvider を providers に加えてください。

binding には D1 と同じくバインディングの名前を渡し、ドライバは最初の操作のときに Workers の env から読みます。r2Disk() はファクトリと、それが作るものの説明を一緒に返すので、introspect したアプリはディスクを作らずに { driver: 'r2', binding: 'MEDIA' } と報告します。これで、ストレージガイドにある storage.disk('media').put(...) / get(...) / files(...) がそのまま動きます。bun run dev ではディスクに、wrangler dev と本番では R2 に書き込みます。

R2 は S3 と仕組みが違うため、次の 3 点で挙動が異なります。

  • url() を使うには publicUrl が必要です。 ダッシュボードでバケットにカスタムドメインを割り当て(r2.dev のサブドメインはレート制限があり、開発向けです)、そのドメインを publicUrl に渡してください。R2 には公開 URL を組み立てる規則がないので、設定しないと url() は例外を投げます。
  • temporaryUrl() には S3 の資格情報が必要です。 バインディングでは URL に署名できません。presign: { accountId, bucket, accessKeyId, secretAccessKey }(R2 の API トークン)を渡すか、非公開のファイルはアプリの認証付きルートを通して配信してください。presign がないと、temporaryUrl() はこの対処法を示して例外を投げます。
  • 可視性はオブジェクトごとではなく、バケットごとに決まります。 バケットは公開(カスタムドメイン / r2.dev)か非公開のどちらかで、オブジェクトごとの ACL はありません。ドライバはバケットの visibility(publicUrl があれば既定は 'public')を返します。put({ visibility }) や setVisibility() で逆の値を指定されたときは、対応できたふりをせずに例外を投げます。

Workers には読み取れるローカルファイルがないので、putFile() も例外を投げます。バイト列は自分で読み込み(await file.arrayBuffer())、put() を呼んでください。一度きりの一括投入なら、手元のマシンから bunx wrangler r2 object put my-app-media/<key> --file <path> を実行するほうが簡単です。

Bun のプロセス(スクリプトや、Workers 以外へのデプロイ)から同じバケットを使う場合は、S3 互換のエンドポイントと S3 ドライバを使います。endpoint: 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com'、region: 'auto'、R2 の API トークンを組み合わせます。詳しくはストレージガイドを参照してください。

Workers でのアタッチメント

アタッチメントレイヤーは Workers でも動きます。ただし Workers には画像デコーダがないため、画像の処理だけはリクエストの中で行わず、キューのワーカーに任せます。

  • 同期的なチェックは Worker の中でそのまま走ります。 バイト数の超過(413)、maxPixels を超えるヘッダの寸法(422)、HEIC のシグネチャ(415)、image: 'require' のコレクションに渡された画像以外のファイル(422)は、どれもリクエストの中で拒否されます。これらは純粋な JavaScript で書かれていて、デコーダを必要としません。
  • queued: true でアタッチします。 Worker はオリジナルを保存し、宣言したバリアントを pending として記録して、Redis を使うキュー(RedisQueueDriver。キューガイドで Workers に必要としているストアと同じもの)に GenerateVariantsJob をディスパッチします。ジョブが終わるまで、バリアントの URL はオリジナルにフォールバックします。
  • ワーカーは Bun で動かします。 別プロセスの Bun(Bun.Image あり)がキューを処理し、後回しにしたフルデコード、HEIC の変換(有効にしたコレクションのみ)、バリアントの生成を行います。ジョブは configureAttachments() が登録するので、アプリの config を読み込んで起動するワーカーなら、ほかに何も組み込まなくても処理できます。
  • private なアタッチメントは、署名配信ルートを使えばバインディングだけで配信できます。 configureAttachments() でディスクを private と宣言し、ルートを有効にしてください: disks: { media: 'private' }, delivery: {}(宣言していないディスクは public として扱われ、publicUrl を設定したバケットも既定で public です)。さらにルート登録関数で registerAttachmentRoutes(router) をマウントすると、private なアタッチメントの URL はパス相対の署名付き URL になり、ルートが get().body を Worker 経由でストリーム配信します。presign の資格情報は要りません。presign を設定すると、ドライバが capabilities.presignedGet を宣言するので、同じルートが有効期間の短い presigned URL への 302 リダイレクトで応答するようになります。R2 に固有の注意点として、R2 の S3 API は response-content-* によるヘッダの上書きを無視するので、リダイレクト先ではオブジェクトに保存済みのメタデータで配信されます。presign に対応した R2 ディスクで Content-Disposition: attachment を必ず付けたいアプリは、serve: 'proxy' を使ってください。public なアタッチメントはこれまでどおりバケットのカスタムドメインから配信されるので、Worker の CPU は使いません。

ヘッダの内容と中身が食い違うバイト列だけは、同期的なチェックでは見つけられず、受け付けたあとでワーカーが見つけます。image: 'require' のコレクションではジョブがそのアタッチメントをパージし、それ以外では中身を解釈しないファイルとして残ります。

シークレット

APP_KEY は必須です。セッションと CSRF の署名に使うもので、設定されていないとワーカーは起動時に例外を投げ、リクエストを 1 件も処理しないまま止まります。

bun -e "console.log('base64:'+Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))" | bunx wrangler secret put APP_KEY

アプリが読むほかの値(OAuth の認証情報、API キーなど)も同じ方法で設定し、それぞれを config/env.ts で宣言します。vars とシークレットはワーカーのエントリポイントの env に渡され、process.env に入っているとは限りません。@guren/plugin-cloudflare がアプリのブート前にこの env をバインドし、スキーマはそこから値を読むので、アプリのコードはローカルと同じように検証済みの値を使えます(設定)。

wrangler.jsonc の vars には、秘密ではない値だけを書いてください。

無料プランの制限

アプリの設計を左右する制限が 2 つあります。

制限 値 意味
ワーカーサイズ 非圧縮で 64 MiB(全プラン共通) 生成するコンテンツはバンドルに入れず、Static Assets、KV、R2 に置く
リクエストあたりの CPU 10 ミリ秒 重い処理はビルド時か保存時に回す

Cloudflare は 2026-09-04 に圧縮後のサイズの上限(無料 3 MB、有料 10 MB)を撤廃し、いまは非圧縮のバンドルサイズだけを検査しています。アプリのサイズは bunx guren cloudflare:size(または cloudflare:build --report-size)で測れます。wrangler のドライランを実行し、パッケージごとの内訳を大きい順に表示するので、いつの間にかバンドルが膨らんでいたときの原因がそこで分かります。

この数字には表れない制限もあり、起動時間(1 秒、wrangler check startup)とメモリ(isolate あたり 128 MB)がそれです。どちらも、大きなデータをバンドルすると消費されます。

この CPU 制限があるので、パスワードのハッシュ化は事実上できません。わざと遅くしてある処理を 10 ミリ秒に収めるのは無理だからです。無料プランでは、パスワードではなく OAuth で認証してください。OAuth のフローは OAuth ガイドで説明しています。

この制限の下では、リクエスト時の処理を減らす設計が有利です。Markdown の変換は読み取り時ではなく保存時に行い、静的なコンテンツはビルド時にレンダリングしておきます。それでも処理時間がどうしても足りない場合は、有料プランにすると CPU の制限が緩和されます。

可観測性

Workers は、既定ではログを保持しません。そのままだと、本番で問題が起きたときに原因を追うのが非常に難しくなります。

{
  "observability": {
    "enabled": true
  }
}

問題を再現しながらリアルタイムで出力を見たいときは、bunx wrangler tail も併せて使ってください。

ローカル開発

wrangler dev は、ローカルの D1 を相手に本物のランタイムを動かします。Bun の開発サーバーでは見つけられないランタイムの違いを拾えるので、デプロイ前に一度は試しておく価値があります。

bunx wrangler d1 migrations apply my-app --local
bunx wrangler dev

ローカル用のシークレットは .dev.vars に置きます(.gitignore への追加も忘れずに)。

APP_KEY=base64:...

日々の開発では、通常の bun run dev のほうが速く回せます。wrangler dev は、デプロイの直前や、本番でだけ挙動が違うときに使ってください。

静的アセット

public/ の下のファイルはすべて .cloudflare/assets/ にステージングされ、Workers Static Assets から配信されます。Static Assets はワーカーが動くより前に応答するので、ビルドが次の 2 点に対処します。

  • ステージングしたファイルと同じ場所に _headers を生成し、ブラウザがドキュメントとして描画する形式 (.html、.htm、.svg、.xhtml、.xml) に Content-Disposition: attachment と X-Content-Type-Options: nosniff を付けます。フレームワークがローカルで public/ に適用しているのと同じ方針ですが、ここではワーカーにそれを適用する機会がないためです。画像・スクリプト・スタイルシート・フォントは対象外です。public/ に自分で _headers を置いている場合、そのファイルは残り、生成されたルールはその前に挿入されます。プラットフォームが読むルールは最大 100 件で、それを超えた分はエラーにならずに無視されるので、合わせて 100 件を超えるとビルドが警告を出します。また、照合は大文字と小文字を区別します。1 つのルールにスプラットは 1 つしか書けず、グロブでは回避できないため、Logo.SVG のようなファイルには専用のルールを生成します。拡張子を小文字で揃えているアプリなら、追加のルールは生成されません。
  • assets バインディングに "html_handling": "none" を設定し、ステージングした page.html を /page.html でだけ配信します。プラットフォームの既定では /page でも応答するので、アプリにある同じ名前のルートを覆い隠してしまううえ、_headers のルールが照合するパスからファイルが外れてしまいます。ステージングしたファイルで応答できないリクエストはワーカーに渡り、アプリのページはそこから返されます。

public/ の HTML を拡張子なしの URL で配信したい場合は、wrangler.jsonc に "html_handling" を自分で書いてください。値が書かれていれば、ビルドはそれに手を付けません。ただし、その分 .html のルールの効き目は弱くなります。

翻訳

Workers には lang/ を読み込むファイルシステムがないので、ビルドが lang/<locale>/*.json をすべてワーカーに埋め込み、createApp({ i18n }) はそこから翻訳を返します。JSON ファイルはいつもどおり編集し、ビルドし直してください。i18n の path オプションを指定しているアプリは対象外です。サーバーレスとバンドルカタログを参照してください。

スケジュールタスク

生成されるワーカーは fetch と一緒に scheduled ハンドラも export するので、アプリが createScheduler() で登録したタスクは Cloudflare の cron トリガーから実行されます。Workers には scheduler を動かし続ける常駐プロセスがないため、scheduler.start() は実行されません。時間を進める役割はトリガーが受け持ちます。

用意するものは 2 つあります。1 つ目は、Bun サーバーのときとまったく同じ、タスクと、scheduler をバインドするプロバイダです。タスクを app/Console/Kernel.ts に宣言しておくと、guren schedule:list と guren schedule:run からも見えるようになります。必要なのはこのファイルに書くことだけで、書き方はこれらのコマンドが受け付ける 2 つの形のどちらでもかまいません(CLIから見えるようにする)。

// app/Console/Kernel.ts
import { Schedule, defaultContainer, type SessionManager } from '@guren/core'

export function scheduleTasksKernel(): Schedule {
  const schedule = new Schedule()

  // 解決はカーネルの構築時ではなくタスクの実行時です。`session` を束縛するのは
  // app.boot() で、cron のエントリポイントはそれを先に await します。
  schedule
    .call(() => defaultContainer().make<SessionManager>('session').pruneExpired())
    .hourly()
    .name('sessions:prune')

  return schedule
}
// app/Providers/SchedulingProvider.ts
import { ServiceProvider, createScheduler } from '@guren/core'
import { scheduleTasksKernel } from '../Console/Kernel.js'

export default class SchedulingProvider extends ServiceProvider {
  register(): void {
    this.container.singleton('scheduler', () => {
      // logger を渡さないと、例外を投げたタスクは捕捉されて捨てられます (後述)。
      const scheduler = createScheduler({ logger: console.log })
      for (const task of scheduleTasksKernel().buildTasks()) {
        scheduler.addTask(task)
      }
      return scheduler
    })
  }
}

2 つ目は、wrangler.jsonc に書くトリガーそのものです。トリガーはアプリにタスクがあってもなくても起動し、そのたびに課金されるので、ビルドはこれを生成しません。

{
  "triggers": { "crons": ["* * * * *"] }
}

トリガーが起動するたびに、その時刻に実行予定のタスクだけが実行されます。そのため、トリガーの間隔がいちばん細かいタスクより粗いと、そのタスクは一度も実行されません。たとえば ["0 * * * *"] のトリガーと .dailyAt('03:30') のタスクでは、時刻が一致することがありません。トリガーをスケジュールの最小単位に合わせるか、トリガーが起動する時刻にだけタスクを組んでください。

Workers に固有の制約は次のとおりです。

  • schedule.command() は動きません。 node:child_process 経由でシェルに処理を渡しますが、Workers にはサブプロセスを実行する仕組みがありません。ビルドは通り、タスクを実行したときに失敗します。schedule.call() か schedule.job() を使い、処理を直接呼び出してください。上の例は、sessions:prune をプロセス内で実行する形で書いたものです。
  • scheduler のバインディングが見つからないと、トリガーは例外を投げます。 何も掃除していないのに成功と報告することはありません。エラーメッセージに対処法が書かれていて、ほかのワーカーの例外と同じように wrangler tail に出ます。
  • タスク自身が投げた例外は、外に投げられません。 runDueTasks() はタスクごとに例外を捕まえて scheduler の logger に報告しますが、その既定値は何もしない関数です。そのため、D1 のエラーで定期処理が失敗しても成功として報告され、wrangler tail にも何も出ません。scheduler を作るときに logger を渡してください。
  • preventOverlapping() は、起動をまたいでは効きません。 これはタスクがメモリ上に持つフラグで、起動のたびに新しい isolate になる可能性があるためです。トリガーの間隔より時間のかかるタスクは、前回の実行と重なります。タスクを間隔より短く収めるか、isolate より長く残る場所にガードを置いてください。候補は D1 の行か、インスタンスごとの永続的な state と alarm によるスケジュールを持つ Durable Object です(永続エージェント)。特定の identity を持たないアプリ全体の定期処理には cron トリガーが合います。一方、重なってはいけないジョブには identity があるので、Durable Object で表します。
  • runOnOneServer() には、isolate 間で共有する SchedulerLock が必要です。 既定の MemorySchedulerLock は 1 つの isolate の中にしか存在しないので、ここでは何も守れません。スケジューラは警告を出したうえで、起動のたびにタスクを実行します。Workers には Redis がなく、KV には条件付きの書き込みがないので、SchedulerLock は一意キーへの D1 の INSERT か Durable Object を使って実装し、createScheduler({ lock }) に渡してください。

--mcp-oauth を使ったワーカーでは、もう 1 つ掃除が走ります。タスクを実行する前に、前回の掃除から 1 時間たったかを毎回確かめ、たっていれば OAuth プロバイダの purgeExpiredData を呼びます。クライアントが削除されて取り残された付与記録(grant)には有効期限がなく、KV の TTL では消えないので、これを取り除けるのはこの掃除だけです。

1 回の掃除では、まず付与記録を最大 15 件調べ、続く 2 回目の呼び出しで token を最大 15 件調べます。プロバイダは付与記録を調べる段階で予算を使い切るとそこで戻ってしまい、その後ろの token まで一度も届かないので、呼び出しを 2 回に分けています。どちらも毎回キー空間の先頭から調べます。プロバイダのカーソルは呼び出しをまたいで残らないため、その範囲より後ろのレコードには届きません。途中で打ち切られた場合は、その旨が wrangler tail に出ます。

Free プランでは 1 回の起動で使えるサブリクエストが 50 までで、KV の読み取りもそこに数えられます。しかもこの予算はアプリ自身のタスクと分け合うので、件数をここまで小さくしています。ワーカーの側からは、どのプランで動いているかを判別できません。

掃除が失敗してもログに出るだけで、その回は済んだものとして次の間隔まで待ち、アプリのタスクは止まりません。

この掃除にも、ほかと同じく triggers.crons のエントリが必要です。掃除のためだけにトリガーを追加する場合でも scheduler のバインドは必要で、ないと毎回の起動で、掃除のあとに上で説明した例外が投げられます。

永続エージェント

Workers は、アプリケーションが自分でホストするエージェントの基盤にもなります。Durable Object を使うと、長く動き続けるエージェントに、永続的な identity、永続的な state、alarm によるスケジュールを持たせられます。@guren/plugin-agents は、それらを、ルートですでに宣言しているエージェントツールの裏側に置きます。

アプリに config/agents.ts があると、cloudflare:build は次の 3 つを追加で行います。1 つ目は、登録されたクラスごとの名前付き export を生成ワーカーに追記することです。2 つ目は、コミット済みの wrangler.jsonc がそれぞれのクラスを SQLite をバックエンドにした Durable Object としてホストしているかの検証です。足りなければ、バインディングのないままデプロイすることはせず、追記すべき JSON をそのまま出力して失敗します。3 つ目は、/agents/* を、レジストリ自身の authorizer の後ろにすべて拒否する設定でマウントすることです。

durable_objects と migrations のエントリは手で書かず、ビルドを実行してその出力を貼り付けてください。

デプロイしたエージェントの無料プランでの実測値、1 回の定期処理で使える D1 クエリの予算、そのほかエージェントの書き方全般は永続エージェントにまとめてあります。

cf CLI でのデプロイ(ベータ)

cf は Wrangler の後継として Cloudflare が出した CLI で、いまはオープンベータです。wrangler.jsonc の代わりに cloudflare.config.ts を読みます。アプリに cloudflare.config.ts があり、cf パッケージを依存に入れていると、cloudflare:build は cf 向けの出力に切り替わります。このファイルがなければ、これまでと何も変わりません。

デプロイするマシンには Node 22.18 以上が必要です。cf は cloudflare.config.ts を Node で読み込むからです。バンドルは cf から Wrangler に任されるので、Wrangler 4.136 以上も入れておきます。

bun add -d cf wrangler

bunx guren cloudflare:init を実行すると、この経路で読む 3 つのファイル(cloudflare.config.ts、wrangler.config.ts、config/cloudflare.ts。中身は下のとおり)を書き出し、.gitignore に .cloudflare/ を加えます。すでに wrangler.jsonc があるアプリでは、ワーカー名、互換性の日付、DB のデータベース名、OAUTH_KV の名前空間(cloudflare.config.ts に ID で書きます)を引き継ぐので、最初の cf のデプロイも Wrangler のときと同じリソースに結び付きます。cf が読まなくなるほかの設定(routes、vars、ほかのバインディング)は名前を挙げて知らせるので、スプレッドのあとに書き足してください。既存のファイルは、--force を付けない限りそのまま残します。package.json の cf と wrangler がないか古いときは、この経路に必要なバージョンまで上げて、bun install を実行するよう知らせます。

Durable Object の宣言は、Wrangler の migrations から cf の exports に移ります。config/agents.ts に登録したエージェントは gurenWorker() が宣言し、cloudflare:build が生成するワーカーも export するのはそのクラスだけです。それ以外にマイグレーションで生きているクラス、別のワーカーへのバインディング、名前の変更・削除・移管は、cf では自分で宣言するまでどこにも書かれないので、init が名前を挙げて知らせます。デプロイ済みのクラスがこの移行で保存領域を引き継ぐかはまだ確かめられていないので、先にステージング用のワーカーで試してください。

bunx guren cloudflare:init --plan

--plan を付けると、ファイルを書き出したあとにデプロイの計画まで進みます。Wrangler から移ったアプリなら、すべてのバインディングが Wrangler のときと同じリソースに解決されるので、作るものは表示されないはずです。依存のインストールと cf のログインが必要なので、そこで止まったら bun install のあとにもう一度実行してください。

gurenWorker() は、cloudflare:build が組み立てたワーカーを返します。中身はエントリ、名前で指定した DB バインディング、--mcp-oauth のときの OAUTH_KV、SQLite を使う Durable Object としての各エージェントです。自分の設定は、同じオブジェクトの中でスプレッドのあとに書きます。

// cloudflare.config.ts
import { bindings, defineConfig } from 'cf/config'
import { gurenWorker } from '@guren/plugin-cloudflare/config'

export default defineConfig(async () => {
  const worker = await gurenWorker({ name: 'my-app' })
  return {
    worker: {
      ...worker,
      compatibilityDate: '2026-10-01',
      env: { ...worker.env, MEDIA: bindings.r2({}) },
    },
  }
})
// wrangler.config.ts
import { defineWranglerConfig } from 'wrangler/experimental-config'
import { gurenWranglerBundle } from '@guren/plugin-cloudflare/config'

export default defineWranglerConfig(gurenWranglerBundle())

compatibilityDate は自分で決めて、自分で更新してください。gurenWranglerBundle() は、モジュールのスタブ、ビルド時の define、アセットのディレクトリを返し、keepNames も有効にします。スケジュールカーネルがタスクを登録しているときは、* * * * * の cron トリガーも 1 つ加えます。スプレッドのあとに triggers を書けば、そちらに置き換わります。カーネル以外の場所で登録したタスクや、--mcp-oauth の保存領域の掃除だけを動かしたい場合は、この triggers を自分で書いてください。カーネルを読み込めなかったときはトリガーを加えません。その場合 cloudflare:deploy は、トリガーなしでデプロイせずに止まります。カーネルが読めるようになるか、設定に triggers を書けば先に進みます。

Guren 自身のデプロイ設定は config/cloudflare.ts に書きます。ビルドと cloudflare:deploy はこのファイルを読みますが、動いているアプリは読みません。guren check も、createApp({ config }) に加えるよう求めません。

// config/cloudflare.ts
import { defineCloudflareConfig } from '@guren/plugin-cloudflare/config'

export default defineCloudflareConfig({
  mcpOAuth: true,
  resourceNames: { OAUTH_KV: 'my-app-oauth' },
})
  • mcpOAuth と mcpPath は、この経路では --mcp-oauth と --mcp-path のフラグの代わりになります。ファイルと食い違うフラグは受け付けません。ファイルが設定していないフラグは使いますが、次のビルドに残らないので警告します。
  • resourceNames では、バインディングごとのリソース名を指定します。既定の名前(DB は <worker>、ほかは <worker>-<binding>)が別のプロジェクトと重なるときや、名前の制限に合わないときに使います。KV のバインディングは cloudflare.config.ts に名前を持てないので、ただの cf deploy では既定の名前のままになります。名前を指定したら、cloudflare:deploy でデプロイしてください。Worker にないバインディング(綴りの誤りなど)に名前を付けると、計画に resource-name の警告が出ます。

ビルドしてから、アプリのルートでデプロイします。

bunx guren cloudflare:build
node node_modules/wrangler/bin/cf-wrangler.js build
bunx cf deploy --prebuilt

ここでは、ただの cf build や cf deploy は使えません。cf は package.json に Vite があるとビルドを vite build に任せますが、そのビルドは cf がデプロイする Worker を書き出さないからです。2 行目では、Vite のないアプリで cf が選ぶ Wrangler のビルドを直接実行します(ワークスペースでは、巻き上げられた wrangler パッケージのパスを指定してください)。cloudflare:deploy も同じ方法でビルドします。同じ理由で、cf dev は Worker ではなく Vite の開発サーバーを起動します。

この経路では、ビルドの出力先が .guren/cloudflare/ になり、gurenWorker() が読む guren-worker.json もそこに書かれます。このディレクトリには専用の .gitignore が置かれ、.cloudflare/ の下にある cf のファイルには触りません。バインディングに ID がないので、最初の cf deploy がデータベースと名前空間を作ります。名前での探し方は Wrangler の場合と同じです。

cf d1 migrations apply には、データベースの名前ではなく ID を渡します。bunx cf d1 list --name my-app で ID を調べてから、ビルドが書き出した平坦化済みのマイグレーションを適用してください。既定ではリモートに適用され、--local を付けるとローカルのシミュレーションが対象になります。

bunx cf d1 migrations apply <database-id> --dir .guren/cloudflare/d1-migrations

このディレクトリを使うと、各マイグレーションは wrangler d1 migrations apply と同じ名前で記録されます。そのため、Wrangler でマイグレーションしてきたデータベースも履歴を引き継げます。cloudflare:size が測るのは Wrangler の経路だけです。

デプロイの計画

bunx guren cloudflare:deploy --plan は、ツールを確かめ、ビルドし、cf build を実行してから、D1 と KV の各バインディングをアカウントのリソースと突き合わせます。アカウントには何も書き込みません。

bunx guren cloudflare:deploy --plan

各バインディングは次のどれかとして表示されます。existing は、前回の実行で決まったリソースか、自分で書いた ID のリソースです。adopt は、予定の名前で見つかったものの、まだこのアプリのものとして記録されていないリソースなので、このアプリのものか確かめてください。create on deploy は、デプロイ時に作られるリソースです。KV の名前は、cf deploy 自身が作るときと同じ <worker>-<binding>(_ は - に置き換え)です。止める必要のある問題があると、終了コード 1 で終わります。

  • binding-conflict: cloudflare.config.ts に書いた ID と予定の名前が別のリソースを指しているか、同じ名前のリソースが 2 つあるか、config/database.ts が使う D1 バインディングを Worker が別の種類でバインドしています。
  • resource-missing: 書いた ID のリソースがアカウントにないか、リソースがなくこの実行では作れないか、ストレージのディスクや config/database.ts が Worker にないバインディングを使っています。--ci を付けた実行と、ターミナルのない実行では、--provision を付けない限り何も作りません。
  • resource-name: 予定した R2 バケットの名前が R2 の規則(小文字、数字、ハイフンで 3〜63 文字)に合いません。config/cloudflare.ts の resourceNames で名前を付けてください。
  • source-unreadable: cf がアカウントのリソースを一覧できなかったか、マイグレーションの記録表を読めなかったか、アプリの config/env.ts を読み込めませんでした。推測で進めずに、計画を止めます。
  • tracker-layout: データベースの d1_migrations に、平坦化した名前(<folder>.sql)と入れ子の名前(<folder>/migration.sql)が混在しています。
  • destructive-migration: 未適用のマイグレーションが、-- guren:allow-destructive のコメントなしに何かを削除したり型を狭めたりしています(破壊的マイグレーション)。--allow-destructive を付けると、止めずに警告として表示します。
  • secrets: config/env.ts が本番で必須としている変数に、デプロイ済みのワーカー、設定、--secrets-file のどこにも値がありません。

ビルドは Workers で動くときのアプリを introspect し、アプリが使うバインディングを計画に加えます。r2Disk() で登録した R2 のディスクには <worker>-<binding>(または resourceNames の名前)のバケットが割り当てられ、gurenWorker() がバインドし、データベースと同じように作成されます。ファクトリだけで登録したディスクは、作らないとドライバが分からないので「Not read」に表示します。アプリ用の KV は推論しません。KV のストレージドライバがまだないからです。

計画では、データベースに適用済みのマイグレーションを読み、記録表がすでに使っている形式を選んで、その形式での cf d1 migrations apply の引数を表示します。Wrangler でマイグレーションしてきたデータベースは平坦化したコピーのまま扱うので、同じマイグレーションが二重に適用されることはありません。デプロイで新しく作るデータベースはまだ何も適用していないので、手元のマイグレーションをすべて判定します。secrets については、最初のデプロイでは読む相手のワーカーがまだないので、値を dotenv ファイルに書いて --secrets-file .env.production で渡してください。計画が読むのは、そのファイルに書かれた名前だけです。

事前確認には、PATH 上の node が Node 22.18 以上であること、アプリの Wrangler が 4.136 以上であること、cf がログイン済みであること(bunx cf auth login、CI では CLOUDFLARE_API_TOKEN)が必要です。--json を付けると { plan } を 1 つのオブジェクトとして出力します。

1 つのコマンドでデプロイする

--plan を付けなければ、同じコマンドがそのままデプロイまで進みます。

bunx guren cloudflare:deploy --secrets-file .env.production

計画を表示したあとに 1 度だけ確認を求め、承認するまではアカウントに何も書き込みません。止める必要のある問題が計画にあれば、そこで止まります。承認すると、次の順に進みます。

  1. create on deploy のリソースを作り、解決した各 ID を guren-worker.json に記録します。このファイルは git に含めない出力先にあり、リポジトリには入りません。
  2. ID が変わったときは cf build をやり直します。cloudflare.config.ts が記録と別のリソースを指していれば、そこで止めます。
  3. D1 の Time Travel の bookmark を記録し、未適用のマイグレーションを取り直して、計画で判定したものと同じときだけ適用します。
  4. cf deploy --prebuilt --no-provision を実行します。この時点でリソースがなければ、計画にないリソースを作らずにデプロイを失敗させます。
  5. URL、作ったリソース、適用したマイグレーション、ロールバックの方法を表示します。ロールバックしてもマイグレーションは戻りません。

リソースを削除することはありません。何かを書き込んだあとで失敗したときは、この実行で作ったものを消す cf d1 delete / cf kv namespaces delete のコマンドと、マイグレーションのあとなら bookmark に戻す cf d1 time-travel restore のコマンドを表示します。必要なら自分で実行してください。

bunx guren deploy --target cloudflare を使うと、main への push のたびにこれを実行する GitHub Actions のワークフローを書き出せます。CI では確認に答える人がいないので、デプロイするには --yes を付けます。作るものがないときだけデプロイしたいなら --ci を付けます(--ci --provision で作成も許可します)。--skip-migrations を付けると、マイグレーションを適用せずにデプロイします。同じワーカーのデプロイは、GitHub Actions のワーカーごとの concurrency グループなどで 1 つずつ実行してください。同時に走ると、同じリソースを作ったり、同じマイグレーションを適用したりすることがあります。

既存アプリの更新

wrangler.jsonc は最初に 1 度だけ生成され、それ以降は上書きされません。そのため、プラグインを更新する前に作ったアプリは、元の設定のまま残ります。古い設定を見つけると、ビルドが足りない項目を具体的に出力するので、それを追記してビルドし直してください。

d1_databases や kv_namespaces に残った仮の ID も、ビルドが名指しで警告します。古いプラグインが書いた TODO: の値が典型で、env.<name> のブロックの中も調べます。キーを削除するか、本物の ID に書き換えてください(データベース(D1))。

ただし、triggers.crons だけは出力しません。アプリがスケジュールタスクを登録しているかどうかは、スケジュールカーネルを見つけて読み込めたビルドにしか分からず、ほかの書き方で宣言したタスクは「ない」と判断されてしまうからです。そのため、ビルドは推測で出力することはせず、何も言いません。タスクがあって Workers でも動かしたい場合は、トリガーを手で追記してください(スケジュールタスク)。

デプロイ後

  • Cloudflare のダッシュボードでワーカーに独自ドメインを割り当て、あわせて OAuth のコールバック URL も更新します。
  • 監視とインシデント対応については本番運用ランブックを参照してください。