- Cloudflare Workersで本番を壊すのは、たいていコードではなく設定の効き方。継承するキーとしないキーが混在し、デプロイの経路によって適用される範囲が変わる。
- 扱うのは5つ。routesの環境継承/最小権限トークンのAuthentication error 10000/cron triggersの未反映/R2バケットのリネーム不可/Pagesの拡張子なしURL。それぞれ「何が起きたか→なぜ→どう直すか→再発防止」で記録する。
- 5つともエラーを出さずに失敗する。だから対策は共通で、「設定を書いたこと」ではなく「効いている証跡」で完了を判定する。記事末に証跡の一覧表を置いた。
Cloudflare Workersは、書いたコードがそのままエッジで動く。ローカルでwrangler devが通れば本番でもだいたい動く——という感覚は、コードに関してはおおむね正しい。踏み抜くのは設定のほうだ。
この記事は、自社で開発・運用しているAIインタビューのプロダクトをCloudflare Workers上で動かす中で実際に踏んだ罠を5つ、設定ファイルの実物とともに記録したものです。入門記事ではありません。基本的な使い方は公式ドキュメントが整っているので、ここでは「ドキュメントを読んでいても踏む」失敗だけを扱います。技術的な主張には、確認できた範囲で公式ドキュメントの該当箇所を添えました。
5つに共通するのは、いずれもデプロイが成功したように見えることです。CIは緑になり、ページは表示され、コードは新しくなっている。それでも設定は効いていない。
罠1:stagingにデプロイしたら、本番の独自ドメインが付いた
wrangler.tomlのトップレベルroutesは環境(env)に継承される。[env.staging]側でroutes = []を明示しないと、本番のドメインがstaging Workerに向く。
何が起きたか
検証用のstaging Workerへデプロイしたところ、本番の独自ドメインがstaging側に紐づいた。stagingは外部AI・認証・決済を既定でOFFにし、MOCK = "1"で固定レスポンスを返す環境で、本番のトラフィックを受ける想定はまったくない。デプロイ直後に気づいて修正したが、気づかなければ本番URLが検証用の応答を返し続けていた。
なぜ起きるか
Wranglerの環境(Environments)は、設定キーを「継承するもの」と「継承しないもの」に分けています。公式のWrangler設定ドキュメントの定義はこうです。
- Inheritable keys:「トップレベルで設定でき、環境ごとの設定に継承(またはオーバーライド)される」キー
- Non-inheritable keys:「トップレベルで設定できるが環境には継承されず、環境ごとに指定しなければならない」キー
そしてroutesはInheritable keys側に、vars(環境変数)とバインディング類はNon-inheritable keys側に分類されています。
ここが罠の本体です。同じ設定ファイルの中で、挙動が逆になる。varsは「トップレベルに書いてもenvには降りてこない」ので環境ごとに書き直す必要があり、routesは逆に「トップレベルに書くと黙ってenvにも降りてくる」。前者の感覚で後者を書くと事故ります。手元の設定ファイルには、この非対称性がそのまま残っています。
[env.staging.vars]
APP_ENV = "staging"
MOCK = "1"
TRANSCRIPT_POLISH = "0"
# Top-level vars are not inherited by Wrangler environments. Keep external
# screen/audio AI explicitly disabled in staging; the full respondent E2E uses MOCK.
ALLOW_OPENAI_SCREEN_OBSERVATION = "0"
OPENAI_LIVE_TRANSCRIPTION_ENABLED = "0"
実際のwrangler.tomlより。varsは継承されないため、stagingでも外部AIを明示的にOFFへ倒し直している。「書いていない=OFF」ではなく「書いていない=未定義」なので、安全側に倒すには明示が要る。
どう直すか
[env.staging]にroutes = []を明示します。空配列は「この環境にはrouteを付けない」という宣言で、トップレベルからの継承を打ち消します。
[env.staging]
name = "coesignal-staging"
workers_dev = true
preview_urls = false
# 本番の独自ドメイン(トップレベルroutes)を継承させない
routes = []
同じく実物より。コメントは「なぜ空配列を書いているのか」を将来の自分に説明するために置いている。空配列は一見すると無意味な行に見えるので、理由がないと削除される。
再発防止
二段構えにしました。一段目は、上のroutes = []を恒久的に残すこと。後述の理由でトップレベルからroutesを消したあとも、この行は消していません。将来だれかがトップレベルにroutesを書き戻したときの受け皿になるからです。設定ファイルのガードは、原因が消えても残す。
二段目は、そもそもトップレベルにroutesを書かない設計に倒したこと。これは次の罠と地続きです。
罠2:Authentication error 10000——エラー文がrouteに一言も触れない
CI用の最小権限トークンでroutesを設定に書くと、/zones/*/workers/routesの呼び出しにゾーンスコープの権限が要るため認証エラーで落ちる。メッセージはroutesについて何も言わない。
何が起きたか
GitHub ActionsからのデプロイがAuthentication error [code: 10000]で失敗した。ローカルのwrangler deployは通る。コードもテストも変えていない。変えたのはwrangler.tomlにroutesを足したことだけ。
エラーメッセージは「認証エラー」としか言いません。トークンが失効したのか、シークレットの登録をミスったのか、アカウントIDが違うのか——最初に疑うのはそのあたりで、routesの行に辿り着くまでに時間を溶かします。
なぜ起きるか
Cloudflareの権限モデルは、アカウントスコープとゾーンスコープに分かれています。Workerのスクリプトをアップロードする操作はアカウントスコープですが、Workerをドメイン(route)に紐づける操作はゾーンスコープです。公式のWorkers Builds設定ドキュメントは、デプロイに必要な権限をこう分けて示しています。
| スコープ | 必要な権限 | 何のために要るか |
|---|---|---|
| アカウント | Account Settings (read)/Workers Scripts (edit)/Workers KV Storage (edit)/Workers R2 Storage (edit) | Workerのコードとバインディングを更新する |
| ゾーン | Workers Routes (edit) | Workerを独自ドメイン・routeに紐づける |
| ユーザー | User Details (read)/Memberships (read) | トークンの所属確認 |
出典:Cloudflare公式ドキュメント(Workers Builds — Configuration)・2026年8月時点。
CI用のトークンは最小権限で発行してあります。Workerを更新するのに必要なアカウント側の権限しか持たせていない。そこへroutesを書くと、wranglerは/zones/*/workers/routesを叩きにいきます。トークンにゾーン権限がないので弾かれる。10000はCloudflare APIが権限不足に対して返す汎用の認証エラーコードで、「どのエンドポイントで足りなかったのか」までは教えてくれません。
要するに、routesの1行が、CIトークンに要求する権限の階層を1段上げる。最小権限を徹底しているCIほど刺さります。
どう直すか
選択肢は2つです。
- CIトークンにゾーンスコープのWorkers Routes (edit)を足す
routesを設定ファイルから外し、カスタムドメインの管理をデプロイの経路から分離する
採ったのは2です。カスタムドメインの紐づけはアカウント側に永続するので、毎回のデプロイで宣言し直す必要がありません。1を採ると、コードを更新するだけのトークンが常時ゾーンの書き換え権限を持つことになる。ドメインの付け替えは年に数回、コードのデプロイは日に数回。頻度の低い操作に合わせて、頻度の高い経路の権限を上げるのは割に合わない——付け替え・追加は、ダッシュボードかフル権限のローカルwranglerで行います。
再発防止
設定ファイルに「なぜ書かないか」をコメントで残しました。消した設定は差分から消えるので、意図もいっしょに消えます。残すなら本文に残すしかない。
# 独自ドメイン coesignal.techworker.co.jp は 2026-07-17 のローカルdeployでアタッチ済み。
# カスタムドメインはアカウント側に永続するため、ここには書かない:
# routes をこのファイルに書くと、CIの最小権限 CLOUDFLARE_API_TOKEN(ゾーン権限なし)が
# /zones/*/workers/routes への呼び出しで Authentication error 10000 になりデプロイが落ちる。
# ドメインの付け替え・追加はダッシュボードまたはフル権限のローカルwranglerで行うこと。
実際のwrangler.toml冒頭のコメント。「書かない理由」と「ではどこで設定するのか」をセットで書いておかないと、半年後に誰か(自分を含む)が親切心で書き戻す。
罠3:CIは成功しているのに、cronが動かない
バージョンをアップロードして昇格させる経路(wrangler versions upload)ではトリガーが適用されない。cronを反映するにはwrangler triggers deployが別途必要。
何が起きたか
日次のバッチをcronで回す設定を入れ、CIからデプロイした。ワークフローは成功。Workerのコードも新しくなっている。しかし翌朝、ジョブが動いていない。エラーログすら残らない。「失敗した」ではなく「呼ばれてすらいない」という、いちばん気づきにくい壊れ方でした。
なぜ起きるか
デプロイの経路が2種類あることが原因です。wrangler deployはコードと設定をまとめて反映します。cron triggersについても公式ドキュメントは「Wranglerでデプロイすると、それまでのCron Triggersはtriggers配列で指定したものに置き換えられる」と書いています。
一方、CIで本番を安全に更新するときは、バージョンをアップロードしてから昇格させる流れ(wrangler versions upload→wrangler versions deploy)を使うことが多い。テストの通ったコミットを一度アップロードし、検証を挟んでから切り替えられるからです。この経路はコードのバージョンだけを扱い、トリガーには触れません。
公式ドキュメントの記述は明快です。デプロイ管理のページには「Workerのトリガー(ルート、ドメイン、cron triggers)への変更を適用するにはwrangler triggers deployコマンドを使う」とあり、triggers deployコマンド自身の説明は「wrangler versions uploadを使うときに、トリガー(ルートやドメイン、Cron Triggers)への変更を適用する」です。
CIが緑になったことは、cronが反映された証拠にならない。コードのバージョンとトリガーの設定は、別々の経路で本番に届く。
どう直すか
デプロイのあとにwrangler triggers deployを1ステップ足します。設定側はcronの定義を置くだけです。
[triggers]
crons = [ "15 23 * * *" ]
記法はCloudflare公式ドキュメントより。Cron Triggersは「UTC時刻で実行される」と明記されているので、日本時間の朝8:15に回したければUTCでは前日の23:15になる。日本時間で設計してUTCで書くとき、日付がずれる時間帯があることに注意。
もう一つ、同じページに書かれている挙動も併せて覚えておきたい。cronsに空配列を渡すと全てのトリガーが削除され、プロパティ自体を書かなければ既存のトリガーが維持されます。「1本だけ足すつもり」でcronsを書き換えると、書かなかったぶんが消えます。
再発防止
判定基準を変えました。cronの反映は「デプロイが成功したか」ではなく「スケジュール実行の証跡が着弾したか」で判定する。 日次ジョブなら翌日の通知、時間単位なら次の実行時刻のログ。設定を書いたことは完了ではありません。この判定基準は残り4つの罠にもそのまま効くので、最後の節でまとめて扱います。
罠4:R2バケットは改名できない——プロダクト名を変えたときの設計判断
R2バケットは作成後に名前を変えられない。Worker名は変えられる。だから改名時は「変えられない名前」だけが旧プロダクト名のまま残る。
何が起きたか
プロダクト名を変更しました。Worker名はダッシュボードのリネームで変更でき、secretsやバインディングを保持したまま移行できています(この挙動については公式ドキュメントの明記を見つけられなかったので、実行前にご自身の環境で確認してください)。ところがR2バケットだけが変えられない。結果、いまも設定ファイルには旧プロダクト名由来のバケット名が残っています。
# R2バインディング(音声・トランスクリプト・判定ログ・メモの保存)
[[r2_buckets]]
binding = "AUDIO"
bucket_name = "aiiv-audio"
[[env.staging.r2_buckets]]
binding = "AUDIO"
bucket_name = "aiiv-audio-stripe-test"
実際のwrangler.tomlより(本番とstagingの該当部分を並べたもの)。バインディング名はどちらもAUDIOで、バケット名だけが環境で違う。バインディングはNon-inheritableなので、env側でもう一度宣言する必要がある——罠1の裏返しがここにも出ている。
なぜ起きるか
R2のバケット管理APIに、リネーム操作が存在しないためです。CloudflareのAPIリファレンスにあるバケット操作はList / Get / Create / Patch / Deleteで、Patchが変更するのは「新規アップロード時のデフォルトstorage class」であって名前ではありません。名前を書き換える口はどこにもない。
これは設計上そうなっている、と考えるのが自然です。バケット名はS3互換エンドポイントのパスに現れ、カスタムドメインの紐づけの単位になり、既存オブジェクトの参照の前提にもなる。あとから変えれば、それらすべてを同時に付け替える必要があります。
どう直すか(改名時の設計判断)
3択で考えました。
| 選択肢 | やること | 判断 |
|---|---|---|
| A. 旧名のまま運用 | バケット名は据え置き、コードからはバインディング名で参照する | 採用 |
| B. 新バケットへ移行 | 新規作成→全オブジェクトのコピー→参照の切り替え→旧バケットの廃棄 | 見送り |
| C. 新環境だけ新命名 | 以後に作る環境から新命名にし、既存環境は据え置く | 却下 |
Aを採った理由は単純で、コードにバケット名が出てこないからです。参照はenv.AUDIOというバインディング経由なので、意味を担っているのはバインディング名のほう。バケット名は設定ファイルの2行にしか現れません。
Bは、音声とトランスクリプトという「消せない・移行コストが実データ量に比例する」データを、見た目の統一のために動かすことになります。二重書き込み期間の設計、整合性の確認、切り戻し手順まで含めた作業を、ユーザーに一切見えない改善のために積む理由がない。Cは命名規則が2系統に割れ、どちらが正なのかを毎回考えることになるため却下しました。統一しないなら、統一しないほうに統一したほうがまだましです。
再発防止
ルールを1行にしました。あとから変えられない名前には、プロダクト名を入れない。役割で付ける。
R2バケット、キュー、データベースのように名前が作成時に確定してしまう資源は、audio-storeのように役割で名付ける。逆に、変えられるもの——Worker名、バインディング名、リポジトリ名——にはブランドを入れてよい。ブランドは変わりますが、役割は変わりません。
罠5:Cloudflare Pagesが.htmlを消す——canonicalとsitemapのずれ
Cloudflare Pagesは.html付きURLを拡張子なしURLへリダイレクトする。canonicalとsitemapを拡張子なしで揃えないと、宣言した正規URLと実際に配信されるURLがずれる。
何が起きたか
こちらはWorkersではなくPages側、TechWorkerのコーポレートサイトの話です。記事ページのファイル名はclaude-code-dounyu.htmlで、canonicalもsitemapも.html付きで書いていました。ところがCloudflare Pagesは.htmlを拡張子なしのURLへリダイレクトします。宣言している正規URLがリダイレクト元、実際に配信されるURLがリダイレクト先、という状態になっていた。
なぜ起きるか
公式ドキュメントに明記されている挙動です。「PagesはHTMLページを拡張子なしの対応URLへリダイレクトする。たとえば/contact.htmlは/contactへリダイレクトされる」。ステータスコードまでは公式に記載がありませんが、当サイトで確認した限りでは308(Permanent Redirect)でした。
問題は壊れて見えないことです。ユーザーはリダイレクトされて正しいページに着く。表示は正常。だからブラウザで確認するかぎり、何も起きていないように見えます。ずれているのは、クローラーに渡している正規URLの宣言だけ。
どう直すか
外部に出るURLを拡張子なしに統一します。具体的にはcanonical、og:url、sitemap.xmlの3つ。TechWorkerのサイトでは全50ページを拡張子なしで揃え直しました。
内部リンク(href="claude-code-dounyu.html")は.htmlのままでも動きます。リダイレクトを1回挟むだけなので実害は小さい。ただ、正規URLと内部リンクで書式が割れると監査がしづらくなるので、揃えられるなら揃えたほうがよいでしょう。
再発防止
公開前の機械チェックを1本入れました。canonicalに書いたURLをそのまま叩いて、200が返るか。 308が返ったらcanonicalが間違っています。人間の目視ではまず気づけませんが、この1行のチェックなら確実に捕まる。
sitemapも手書きせず生成する。手書きしているかぎり、いつか必ずファイル名のほうを写してしまいます。
5つに共通する構造——「書いたこと」を完了にしない
5つとも「デプロイは成功したのに設定が効いていない」型。対策は共通で、設定を書いたことではなく、効いている証跡で完了を判定する。
並べてみると、別々の機能の話に見えて、失敗の形が同じです。
- 継承するキーとしないキーが混在している(
routesは継承、varsとバインディングは非継承) - 権限のスコープが操作によって違う(スクリプト更新はアカウント、routeはゾーン)
- デプロイの経路によって、適用される設定の範囲が違う(
wrangler deployとversions upload) - 名前には変えられるものと変えられないものがある(Worker名とR2バケット名)
- 配信レイヤーがURLを書き換える(PagesのHTML拡張子)
どれも「宣言したとおりに効く」という前提が崩れる場所です。そして5つのうち4つは、失敗しても目に見えるエラーを出しません。だから対策も1つに畳めます。
設定は「書いたこと」で完了にしない。「効いている証跡」で完了にする。エラーを出さない失敗は、確認しにいかないかぎり見つからない。
具体的には、罠ごとに「完了と誤認する根拠」と「実際に見るべき証跡」を対にして持っておくのが実務的です。この記事の実質的な結論はこの表になります。
| 罠 | 「終わった」と誤認する根拠 | 実際に見るべき証跡 |
|---|---|---|
| 1. routesの継承 | staging用の設定を書いた | staging Workerに紐づいているドメインの一覧 |
| 2. Authentication error 10000 | ローカルではデプロイできた | CIトークンのスコープにゾーン権限があるか |
| 3. cronが反映されない | CIのワークフローが成功した | スケジュール実行のログ・通知が実際に着弾したか |
| 4. R2バケットの改名 | ダッシュボードで名前を直したつもり | 名前は作成時に確定する。参照はバインディング経由か |
| 5. Pagesの拡張子なしURL | ブラウザでページが表示された | canonicalのURLを直接叩いて200が返るか |
5つのうち4つ(1・2・3・5)は、デプロイのログを見ているかぎり気づけない。CIに足すべきなのは、デプロイのステップではなくデプロイ後の確認のステップ。証跡を1つずつジョブにしていくと、同じ罠を二度踏まなくなる。
Cloudflare Workersは、少ない設定で本番が動く。設定が少ないぶん、1行の意味が重い。routesの1行が権限の階層を変え、バケット名の1行が二度と変えられない。書く前に「これは継承されるか」「これはあとから変えられるか」を確認するだけで、この記事の5つのうち4つは踏まずに済みます。
よくある質問
wrangler.tomlのトップレベルroutesは環境(env)に継承されますか?継承されます。Cloudflare公式のWrangler設定ドキュメントでroutesは「Inheritable keys(トップレベルで設定でき、環境に継承またはオーバーライドされる)」に分類されています。stagingなどの環境に本番ドメインを付けたくない場合は、その環境のセクションにroutes = []を明示して継承を打ち消してください。なお環境変数varsとバインディングは逆に「Non-inheritable keys」で、環境ごとに書き直す必要があります。
Authentication error 10000で落ちます。何を疑えばいいですか?設定ファイルにroutesを書いていないか確認してください。Workerのスクリプト更新はアカウントスコープの権限で足りますが、routeの設定はゾーンスコープのWorkers Routes権限を要求します。Cloudflare公式のWorkers Builds設定ドキュメントでも、必要な権限はアカウント側(Account Settings read、Workers Scripts edit ほか)とゾーン側(Workers Routes edit)に分けて示されています。最小権限で発行したCIトークンにゾーン権限がないと認証エラーになりますが、エラーメッセージはroutesについて何も言いません。
wrangler versions uploadでバージョンをアップロードして昇格させる経路では、トリガーが適用されないためです。Cloudflare公式ドキュメントは「Workerのトリガー(ルート、ドメイン、cron triggers)への変更を適用するにはwrangler triggers deployコマンドを使う」と明記しています。デプロイ後にwrangler triggers deployを実行してください。CIが成功したことはcronが反映された証拠になりません。
変えられません。CloudflareのR2バケット管理APIにはList・Get・Create・Patch・Deleteしかなく、リネーム操作が存在しません(Patchが変更するのは新規アップロード時のデフォルトstorage classで、名前ではありません)。名前を変えたい場合は新しいバケットを作って全オブジェクトを移行することになります。プロダクト名の変更に備えるなら、バケット名にはブランド名ではなく役割を入れておくのが安全です。
.html付きと拡張子なしのどちらにすべきですか?Cloudflare Pagesでホスティングしている場合は拡張子なしに揃えてください。公式ドキュメントに「Pagesは/contact.htmlを/contactへリダイレクトする」と記載があり、canonicalを.html付きで書くと宣言した正規URLがリダイレクト元になってしまいます。canonical・og:url・sitemap.xmlの3つを拡張子なしで統一し、公開前にcanonicalのURLを直接叩いて200が返るか機械的に確認するのが確実です。
参照した公式ドキュメント
- Configuration — Wrangler(Inheritable keys/Non-inheritable keysの定義と
routes・varsの分類) - Environments — Wrangler(環境ごとの設定の書き方)
- Workers Builds — Configuration(デプロイに必要なAPIトークン権限のスコープ別一覧)
- Deployment management — Workers(トリガーの変更には
wrangler triggers deployが必要) - Wrangler commands — Workers(
triggers deployコマンドの定義) - Cron Triggers — Workers(
[triggers]の記法、UTC実行、配列の置き換え挙動) - Cloudflare API — R2 Buckets(バケット操作の一覧。リネーム操作は存在しない)
- Create new buckets — R2(バケット名の命名規則)
- Serving Pages — Cloudflare Pages(HTMLページの拡張子なしURLへのリダイレクト)
いずれも2026年8月時点で確認。Cloudflareの仕様は更新されるため、実装前に最新の公式ドキュメントをご確認ください。
