Terraform で Cloudflare Workers をデプロイし、tfstate を R2 に配置してみた
はじめに
クラウド事業本部、あきやまです。
最近、実務で CloudFront を触る機会が増えました。同じ CDN 周辺の話として Cloudflare もよく聞くので、Workers と R2 を Terraform から使ってみることにしました。今回は、簡易なアプリのデプロイと、tfstate のリモート管理まで実施しています。
環境
| 項目 | 値 |
|---|---|
| OS | macOS |
| Terraform | 1.14 系(required_version = ">= 1.14") |
| Cloudflare Provider | v5.23.0 |
| デプロイ手段 | ローカルから terraform apply |
| state | R2(S3 互換 backend) |
| 公開 URL | *.workers.dev(カスタムドメインなし) |
結論
Terraform の apply で Worker をデプロイできました。workers.dev 上で HTML と JSON が返るところまで確認しています。
作成したリソースは次の 3 つです。
| リソース | 役割 |
|---|---|
cloudflare_worker |
Worker 本体。workers.dev とログ設定 |
cloudflare_worker_version |
worker.mjs のアップロード |
cloudflare_workers_deployment |
その version にトラフィック 100% |
cloudflare_workers_deployment が、アップロードしたコードを実際のトラフィックに載せる工程です。ここまで通ったので、デプロイ完了としています。
tfstate は R2 に置きました。R2 に専用の Terraform backend はありません。公式どおり backend "s3" の endpoint を R2 に向けます。鍵は R2 の Access Key ですが、Terraform が読む環境変数名は AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY です。今回初めて知ったのは、この remote backend が AWS の S3 API と互換である、という点です。
Worker 本体の操作には、それとは別に CLOUDFLARE_API_TOKEN が必要です。R2 のキーだけでは apply 時に認証ヘッダ不足で落ちます。
やってみた
Step 1: 最小の Worker を用意する
構成は次です。アプリと Terraform を分けています。
src/
├── app/worker.mjs # Worker
└── terraform/ # IaC
Worker は HTML と JSON だけです。GET / で HTML、GET /api/hello で JSON を返します。
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/hello") {
return Response.json({
message: "Hello from Cloudflare Workers",
path: url.pathname,
});
}
return new Response(html(), {
headers: {
"content-type": "text/html; charset=utf-8",
},
});
},
};
ストレージ(KV / D1 / R2 のアプリ利用)は入れていません。今回の目的は Terraform だけで Worker をデプロイすることです。tfstate の置き場に R2 を使っています。
Step 2: Terraform で Worker / Version / Deployment を定義する
Cloudflare Provider v5 では、スクリプトを 1 リソースで上げ切るより、Worker・Version・Deployment を分ける形が公式の IaC 例に近いです。今回もそれに合わせました。
resource "cloudflare_worker" "app" {
account_id = var.account_id
name = var.worker_name
observability = {
enabled = true
head_sampling_rate = 1
logs = {
enabled = true
invocation_logs = true
persist = true
}
}
subdomain = {
enabled = true
previews_enabled = true
}
}
resource "cloudflare_worker_version" "app" {
account_id = var.account_id
worker_id = cloudflare_worker.app.id
compatibility_date = "2026-08-23"
main_module = "worker.mjs"
modules = [{
name = "worker.mjs"
content_type = "application/javascript+module"
content_file = "${path.module}/../app/worker.mjs"
}]
}
resource "cloudflare_workers_deployment" "app" {
account_id = var.account_id
script_name = cloudflare_worker.app.name
strategy = "percentage"
versions = [{
percentage = 100
version_id = cloudflare_worker_version.app.id
}]
}
Provider はトークンを HCL に書きません。環境変数 CLOUDFLARE_API_TOKEN を使います。
provider "cloudflare" {}
Step 3: R2 を手で作り、S3 backend にする
tfstate 用バケットは、この Terraform では作りません。先に Dashboard で R2 バケットを作ります。
手順は次です。
- Dashboard の R2 でバケットを作成する(例:
handson-tfstate) - R2 API Token を発行する(Object Read and Write)
- Access Key ID と Secret Access Key を控える
backend は空の s3 ブロックにして、実体は backend.hcl に分けました。鍵は HCL に書かず環境変数にします。
terraform {
backend "s3" {}
}
bucket = "handson-tfstate"
key = "cloudflare-handson/terraform.tfstate"
region = "auto"
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
endpoints = {
s3 = "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com"
}
公式の Remote R2 backend と同じ考え方です。R2 は S3 API 互換なので、S3 クライアントの接続先だけ R2 に変えます。
手元では次を実行しました。
cd src/terraform
cp backend.hcl.example backend.hcl
# Account ID を埋める
export AWS_ACCESS_KEY_ID="<R2 Access Key ID>"
export AWS_SECRET_ACCESS_KEY="<R2 Secret Access Key>"
terraform init -backend-config=backend.hcl
backend.hcl は gitignore しています。
Step 4: ハマったところ
R2 のキーだけでは Worker を作れない
R2 接続後に apply すると、次で落ちました。
POST ".../accounts/.../workers/workers": 400 Bad Request
Missing X-Auth-Key, X-Auth-Email or Authorization headers
AWS_* は tfstate 用です。Cloudflare API には届きません。Worker 用に API Token(Account > Workers Scripts > Edit)を発行し、同じシェルで次を設定して解消しました。
export CLOUDFLARE_API_TOKEN="<Workers 用 API Token>"
整理すると、認証は 2 系統 です。役割が違うので、片方では足りません。
| 環境変数 | 中身 | 誰が読むか | 用途 |
|---|---|---|---|
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
R2 の Access Key ID / Secret Access Key | Terraform の backend "s3"(AWS SDK) |
tfstate の読み書き |
CLOUDFLARE_API_TOKEN |
Cloudflare API Token | Cloudflare Provider | Worker の作成・更新 |
tfstate 側(R2 + S3 backend)
Cloudflare 公式の Remote R2 backend は、R2 専用 backend ではなく backend "s3" を使います。R2 が S3 API 互換 だからです。PutObject / GetObject など、S3 と同じ操作でオブジェクトを扱えます。
接続先だけ AWS ではなく R2 に向けます。endpoint は次の形です。
https://<Account ID>.r2.cloudflarestorage.com
鍵は AWS の IAM ではなく、R2 の Access Key ID / Secret Access Key です。Dashboard の R2 → Manage R2 API tokens で、Object Read & Write のトークンを発行するとこの 2 値が取れます。公式手順も同じです。
Terraform 側の読み方は S3 backend の仕様です。access_key は HCL に書くこともできますが、同じ値を環境変数 AWS_ACCESS_KEY_ID からも取ります。secret_key は AWS_SECRET_ACCESS_KEY です。S3 backend の中が AWS SDK なので、環境変数名は AWS の決まりのままです。AWS アカウントは不要です。
公式の R2 backend 例では、AWS 固有の検査を避けるため次も付けます。R2 には STS や IMDS が無いためです。
region = "auto"skip_credentials_validationskip_metadata_api_checkskip_region_validationskip_requesting_account_idskip_s3_checksum
HashiCorp は、認証情報を HCL に直書きせず環境変数にするのを推奨しています。HCL や -backend-config に書くと、.terraform 配下や plan ファイルに残るためです。今回は backend.hcl には bucket と endpoint だけ書き、鍵は export AWS_* にしました。
Worker 側(Cloudflare API)
Worker の作成は Cloudflare API です。Provider は Create API token で発行した Token を使い、環境変数は CLOUDFLARE_API_TOKEN です。公式の R2 backend ページでも、provider は $CLOUDFLARE_API_TOKEN から取る、と書いてあります。
権限は今回 Account > Workers Scripts > Edit です。R2 の Access Key にはこの権限が無いので、Worker API には Authorization ヘッダが付きません。先の 400 はそれが原因でした。
図にすると次です。
terraform apply
├── backend "s3"
│ AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
│ → https://<Account ID>.r2.cloudflarestorage.com
│ → tfstate の読み書き
└── provider "cloudflare"
CLOUDFLARE_API_TOKEN
→ https://api.cloudflare.com
→ Worker / Version / Deployment
2 本とも Cloudflare の資格情報ですが、製品も API も別です。名前の AWS_ だけが、S3 互換クライアント由来です。
Step 5: apply してデプロイする
plan では 3 リソースの追加になりました。yes で apply した出力は次です。
Plan: 3 to add, 0 to change, 0 to destroy.
cloudflare_worker.app: Creation complete after 0s
cloudflare_worker_version.app: Creation complete after 1s
cloudflare_workers_deployment.app: Creation complete after 0s
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
worker_id = "(Worker の ID)"
worker_name = "handson-hello"
cloudflare_workers_deployment まで作成できたので、Worker はデプロイ済みです。Dashboard の Workers に handson-hello が出て、https://handson-hello.<workers.dev のサブドメイン>.workers.dev で応答します。
ブラウザでは次を確認しました。
GET /… HTML(「Terraform でデプロイした Worker です。」)GET /api/hello… JSON
apply 後、R2 バケットに cloudflare-handson/terraform.tfstate が載りました。init 直後はオブジェクトが空のことがあります。デプロイの実体が state に残るのは apply 後です。
注意点と制約
- R2 バケットは先に手で作る。tfstate の保存先バケットを、同じ構成の初回 apply で作ることはできません。backend の初期化時点で、そのバケットがすでに存在する必要があるためです。
- 鍵が 2 種類ある。R2 Access Key と Workers 用 API Token は別物です。片方だけでは足りません。
backend.hclとterraform.tfvarsはコミットしない。Account ID や実行時の値が入ります。- workers.dev は検証向き。本番なら Custom Domain や Route を別途足します。今回は入れていません。
- R2 の Access Key をシェル履歴に残さない方がよいです。漏れたら Dashboard で失効してください。
まとめ
- Terraform Provider v5 の Worker / Version / Deployment で、最小の Worker をデプロイできました
workers.devで HTML と/api/helloの JSON が返るところまで確認しました- tfstate は公式どおり R2 +
backend "s3"に置きました AWS_*は R2 用、CLOUDFLARE_API_TOKENは Worker 用、と役割が分かれます
「Wrangler なしで Worker を IaC してデプロイしたい」という場合の足がかりになれば幸いです。





