Cloudflare Workersでは、Pull Requestや検証用バージョンごとに個別の動作確認用URLを発行できる Preview URLs 機能が提供されています。しかし、Durable Objects(以下、DO)をWorkerにバインドして直接実装すると、仕様によりPreview URLが自動生成されなくなります。
この制限を回避し、DOによる状態管理を行いながらPreview URLを利用するには、HTTPリクエストを受ける公開WorkerとDOを扱う内部Workerを分離し、両者を Service Binding で接続する構成が有効です。
本記事では、この問題が発生する原因、解決するアーキテクチャ、具体的な実装コード、ローカル開発およびデプロイ手順、注意すべきトレードオフについて解説します。
Cloudflare Workersの公式ドキュメントには、Preview URLの制限項目として以下のように明記されています。
Preview URLs are not generated for Workers that implement a Durable Object.
Worker本体はステートレスですが、DOは特定のIDや名前に対して単一のインスタンスと永続化ストレージ(SQLite / Key-Value等)を維持するステートフルなコンポーネントです。
もしPreview URL経由でバージョンごとの一時的な検証環境から無制限にDOへアクセスできてしまうと、開発中コードによる本番ストレージの破綻やデータ汚染、バージョン間の状態不整合のリスクが生じます。そのため、Cloudflare側で「DOを実装(バインド)するWorker」に対してPreview URLの発行が一律制限されています。
なお、公開Worker側の設定に durable_objects バインディング(外部参照の script_name を含む)が残っているだけでも制限対象と判定される場合があるため、公開WorkerからはDOバインディングを完全に排除する必要があります。
公開WorkerからDOバインディングを除去するため、システムを以下の2つのWorkerに役割分離します。
Browser / Client
|
v
public-api Worker ── Service Binding ──> state-worker
Preview URL利用可 Durable Objectをバインド
Preview URL生成不可
|
v
Durable Object State
public-api(公開Worker):
HTTPエンドポイント、ルーティング、認証・認可を担当します。DOを直接バインドせず、Service Bindingのみを定義するため Preview URLが生成されます。state-worker(内部Worker):
DOのクラス定義とストレージ操作を担当します。インターネットへ直接公開せず、public-api からの Service Binding 経由でのみ呼び出されます。ルームごとにカウンターを保持するシンプルなアプリケーションを例に実装します。
workers-app/
├── public-api/
│ ├── src/index.ts
│ └── wrangler.jsonc
└── state-worker/
├── src/index.ts
└── wrangler.jsonc
state-worker)DOクラスを定義し、バインドします。このWorkerにはPreview URLが生成されません。
state-worker/wrangler.jsonc{
"name": "state-worker",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"durable_objects": {
"bindings": [
{
"name": "ROOM",
"class_name": "RoomDO"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": ["RoomDO"]
}
]
}
state-worker/src/index.tsimport { DurableObject } from 'cloudflare:workers';
export interface Env {
ROOM: DurableObjectNamespace;
}
export class RoomDO extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/increment") {
const current = (await this.ctx.storage.get<number>("count")) ?? 0;
const count = current + 1;
await this.ctx.storage.put("count", count);
return Response.json({ count });
}
if (request.method === "GET" && url.pathname === "/count") {
const count = (await this.ctx.storage.get<number>("count")) ?? 0;
return Response.json({ count });
}
return new Response("Not Found", { status: 404 });
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get("roomId");
if (!roomId) {
return Response.json({ error: "roomId is required" }, { status: 400 });
}
const id = env.ROOM.idFromName(roomId);
const stub = env.ROOM.get(id);
return stub.fetch(request);
},
};
public-api)services に state-worker への Service Binding を指定します。durable_objects の設定は含めません。
public-api/wrangler.jsonc{
"name": "public-api",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"workers_dev": true,
"preview_urls": true,
"services": [
{
"binding": "STATE_SERVICE",
"service": "state-worker"
}
]
}
public-api/src/index.tsexport interface Env {
STATE_SERVICE: Fetcher;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (!url.pathname.startsWith("/api/rooms/")) {
return new Response("Not Found", { status: 404 });
}
const roomId = url.pathname.split("/")[3];
if (!roomId) {
return Response.json({ error: "roomId is required" }, { status: 400 });
}
// 内部通信用URLを構築して Service Binding 経由で呼び出し
const targetPath = url.pathname.endsWith("/increment") ? "/increment" : "/count";
const internalUrl = new URL(`https://state.internal${targetPath}`);
internalUrl.searchParams.set("roomId", roomId);
return env.STATE_SERVICE.fetch(
new Request(internalUrl, {
method: request.method,
headers: { "content-type": "application/json" },
})
);
},
};
Service Binding で接続された複数のWorkerをローカル開発環境で起動するには、wrangler dev コマンドに複数の設定ファイルを渡します。
npx wrangler dev \
-c public-api/wrangler.jsonc \
-c state-worker/wrangler.jsonc
先頭に指定した public-api がメインの受信用Worker(デフォルトで http://localhost:8787)として起動し、後ろの state-worker は内部Workerとしてローカルで連携動作します。
動作確認の例:
# カウントアップ
curl -X POST "http://localhost:8787/api/rooms/room-1/increment"
# カウント取得
curl "http://localhost:8787/api/rooms/room-1"
デプロイ時は、呼び出し先となる内部Workerを先にデプロイし、その後に公開Workerをデプロイ・アップロードします。
# 1. 内部Workerをデプロイ
npx wrangler deploy -c state-worker/wrangler.jsonc
# 2. 公開Workerのバージョンをアップロード(Preview URLを生成)
npx wrangler versions upload -c public-api/wrangler.jsonc --preview-alias pr-100
public-api はDOを直接バインドしていないため、アップロード結果として pr-100-public-api.<subdomain>.workers.dev のようなPreview URLが正常に返されます。
Preview URLが生成可能になった際、最も注意すべきなのはデータの書き換えリスクです。
もしPreview URL用の public-api が、本番用の state-worker に接続されている場合、検証環境からのアクセスによって本番のDOデータが書き換えられるリスクが生じます。
wrangler.jsonc の env 設定を活用し、検証環境・ステージング環境では専用の state-worker-staging に接続するように設定します。
{
"name": "public-api",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"preview_urls": true,
"services": [
{
"binding": "STATE_SERVICE",
"service": "state-worker" // 本番環境用
}
],
"env": {
"staging": {
"name": "public-api-staging",
"services": [
{
"binding": "STATE_SERVICE",
"service": "state-worker-staging" // 検証・ステージング用
}
]
}
}
}
デプロイ時も --env staging オプションを活用し、本番環境と検証環境のデータストアを物理的に隔離してください。
構成を分離することでメリットが得られる一方、運用上のコストや複雑さが増加します。以下のトレードオフを理解したうえで導入を検討してください。
| 評価項目 | DOを単一Workerに直接バインド | 別Worker分離構成(Service Binding) |
|---|---|---|
| Preview URL | 生成不可(仕様上の制限) | 生成可能(public-api側で利用可能) |
| 構成のシンプルさ | 単一プロジェクト・1つの設定で完結 | 2つのWorkerプロジェクトと接続管理が必要 |
| アーキテクチャの関心分離 | HTTP処理とDO状態管理が同居 | API層(認証・ルーティング)と状態層が明確に分離 |
| 通信オーバーヘッド | 同一コンテキスト内の直接呼出 | Service Binding経由の呼び出し(オーバーヘッドは極めて軽微) |
| 環境・データ分離の管理 | 1つのWorkerのみ管理 | Preview/Staging環境用の内部Worker・DOデータの分離管理が必要 |
| デプロイ・CI/CD順序 | 1ステップでデプロイ完了 | 内部Workerを先にデプロイしてから公開Workerをデプロイ |
public-api) と、DOを管理する内部Worker (state-worker) に分離し、Service Bindingで連携させる。