Summary
cloudflare_worker_version accepts a placement block in its schema, but placement is never actually applied. The provider sends it to the versions endpoint (POST /accounts/{id}/workers/scripts/{name}/versions), which does not support placement. The correct endpoint is PATCH /accounts/{id}/workers/scripts/{name}/settings (script-and-version-settings). Additionally, the Read() call to Versions.Get() never returns placement (not part of the version resource at the API level), so state always shows placement = null after every read — causing a forced replacement on every subsequent tofu plan.
Provider version
cloudflare/cloudflare 5.20.0 (OpenTofu 1.12)
Reproduction
resource "cloudflare_worker_version" "api_proxy" {
account_id = var.cloudflare_account_id
worker_id = "my-worker"
compatibility_date = "2026-06-17"
main_module = "worker.js"
placement = {
region = "gcp:us-east1"
}
modules = [{
name = "worker.js"
content_type = "application/javascript+module"
content_base64 = filebase64("worker.js")
}]
}
tofu apply — succeeds, no error
- CF dashboard → Worker → Settings → Placement → still shows Default (not Region)
tofu plan immediately after apply → shows forced replacement due to placement drift
- Repeat forever
Root cause
Create() in internal/services/worker_version/resource.go calls only Workers.Beta.Workers.Versions.New(), which maps to POST /accounts/{id}/workers/scripts/{name}/versions. This endpoint does not accept placement configuration — placement is not a documented field in the versions upload metadata.
The correct endpoint for placement is PATCH /accounts/{id}/workers/scripts/{name}/settings (script-and-version-settings), which the resource never calls.
Additionally, Read() calls Versions.Get(), which returns no placement data (placement is a script-level setting, not version-level), so placement is written as null to state after every read. Since placement is marked Optional only (not Computed), Terraform sees config ≠ state and marks the resource for forced replacement on every plan.
Contrast with cloudflare_workers_script
cloudflare_workers_script uses PUT /accounts/{id}/workers/scripts/{name} (multipart), which does accept placement in the metadata blob. Placement is marked Optional+Computed on that resource, so it round-trips correctly. This confirms the API supports placement — the issue is specific to cloudflare_worker_version using the wrong endpoint.
Expected behavior
cloudflare_worker_version should either:
- Make a secondary
PATCH .../settings call after Versions.New() to set placement (matching what wrangler does), or
- Clearly document that
placement on cloudflare_worker_version is unsupported and remove the field from the schema to prevent silent misconfiguration
Observed behavior
- Placement silently ignored — CF dashboard always shows Default regardless of config
- Every
tofu plan after apply shows forced replacement due to placement drifting to null in state
- No error or warning surfaced to the user
Workaround
Remove placement from cloudflare_worker_version and set it via null_resource + local-exec:
resource "null_resource" "worker_placement" {
triggers = {
worker_version = cloudflare_worker_version.api_proxy.id
}
provisioner "local-exec" {
command = <<-EOT
curl -s -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${var.cloudflare_account_id}/workers/scripts/${var.worker_id}/settings" \
-H "Authorization: Bearer ${var.cloudflare_api_token}" \
-H "Content-Type: application/json" \
-d '{"placement":{"region":"gcp:us-east1"}}'
EOT
}
}
Or set placement manually in the CF dashboard and add lifecycle { ignore_changes = [placement] } to suppress the drift.
References
Summary
cloudflare_worker_versionaccepts aplacementblock in its schema, but placement is never actually applied. The provider sends it to the versions endpoint (POST /accounts/{id}/workers/scripts/{name}/versions), which does not support placement. The correct endpoint isPATCH /accounts/{id}/workers/scripts/{name}/settings(script-and-version-settings). Additionally, theRead()call toVersions.Get()never returns placement (not part of the version resource at the API level), so state always showsplacement = nullafter every read — causing a forced replacement on every subsequenttofu plan.Provider version
Reproduction
tofu apply— succeeds, no errortofu planimmediately after apply → shows forced replacement due toplacementdriftRoot cause
Create()ininternal/services/worker_version/resource.gocalls onlyWorkers.Beta.Workers.Versions.New(), which maps toPOST /accounts/{id}/workers/scripts/{name}/versions. This endpoint does not accept placement configuration — placement is not a documented field in the versions upload metadata.The correct endpoint for placement is
PATCH /accounts/{id}/workers/scripts/{name}/settings(script-and-version-settings), which the resource never calls.Additionally,
Read()callsVersions.Get(), which returns no placement data (placement is a script-level setting, not version-level), soplacementis written asnullto state after every read. Sinceplacementis markedOptionalonly (notComputed), Terraform sees config ≠ state and marks the resource for forced replacement on every plan.Contrast with
cloudflare_workers_scriptcloudflare_workers_scriptusesPUT /accounts/{id}/workers/scripts/{name}(multipart), which does accept placement in the metadata blob. Placement is markedOptional+Computedon that resource, so it round-trips correctly. This confirms the API supports placement — the issue is specific tocloudflare_worker_versionusing the wrong endpoint.Expected behavior
cloudflare_worker_versionshould either:PATCH .../settingscall afterVersions.New()to set placement (matching what wrangler does), orplacementoncloudflare_worker_versionis unsupported and remove the field from the schema to prevent silent misconfigurationObserved behavior
tofu planafter apply shows forced replacement due toplacementdrifting to null in stateWorkaround
Remove
placementfromcloudflare_worker_versionand set it vianull_resource+local-exec:Or set placement manually in the CF dashboard and add
lifecycle { ignore_changes = [placement] }to suppress the drift.References