Manage a private-by-default Cloudflare R2 object-storage bucket β targeting
cloudflare/cloudflare ~> 5.0.
This module manages one cloudflare_r2_bucket:
- π Private by default β the bucket resource carries no public-access toggle. Public exposure requires a separate, deliberately-added custom/managed domain resource, which this module intentionally does not manage.
- π Data residency β pin a
jurisdiction(eu,fedramp) when regulatory requirements dictate where objects are stored. - π¦ Storage class β choose
StandardorInfrequentAccessfor new objects.
π‘ Why it matters: object storage is where sensitive data lands. Keeping the bucket private by construction β with no exposure flag on the resource at all β means a caller cannot accidentally make it public through this module. Exposure is a separate, explicit decision.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
graph LR
acct["Cloudflare account (account_id)"]:::ext
r2["terraform-cloudflare-r2-bucket (this module)"]:::this
res["cloudflare_r2_bucket"]:::keystone
wk["terraform-cloudflare-workers-script"]:::sib
s3["S3-compatible clients / bindings"]:::ext
acct -->|"account_id"| r2
r2 -->|"manages"| res
r2 -->|"bucket name binding"| wk
r2 -->|"object storage"| s3
classDef this fill:#F38020,color:#fff,stroke:#F38020;
classDef keystone fill:#FBAD41,color:#000,stroke:#FBAD41;
classDef sib fill:#f5f5f5,color:#333,stroke:#cccccc;
classDef ext fill:#eeeeff,color:#333,stroke:#9999ff;
graph TD
aid["account_id"]:::in
b["bucket = name, location, jurisdiction, storage_class"]:::in
this["cloudflare_r2_bucket.this"]:::this
oid["output: id and name"]:::out
ol["output: location"]:::out
oj["output: jurisdiction"]:::out
aid --> this
b --> this
this --> oid
this --> ol
this --> oj
classDef this fill:#F38020,color:#fff,stroke:#F38020;
classDef in fill:#f5f5f5,color:#333,stroke:#cccccc;
classDef out fill:#eeeeff,color:#333,stroke:#9999ff;
Resource inventory
| Resource | Name | Cardinality | Role |
|---|---|---|---|
cloudflare_r2_bucket |
this |
1 (keystone) | A private R2 object-storage bucket. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | cloudflare/cloudflare ~> 5.0 |
| Provider block | None β the caller configures the provider and supplies CLOUDFLARE_API_TOKEN out of band. |
| Scope | account_id (per-resource input, not provider config). |
Schema notes that bite (verified against the live provider schema):
- π
locationis immutable and best-effort: it is only honored the first time a bucket of a given name is created. Recreating a bucket with the same name reuses the original location. - π No public-access field. The resource cannot make a bucket public; that is a separate resource (custom/managed domain) intentionally not included here.
- π
jurisdiction(default/eu/fedramp) guarantees where objects are stored β set it for data-residency requirements. It is effectively fixed for the bucket. - βΉοΈ
idequals the bucketname. There is no separate opaque identifier. - βΉοΈ
storage_class(Standard/InfrequentAccess) sets the default class for newly uploaded objects.
Workers R2 StorageΒ· Write (create and manage buckets)Workers R2 StorageΒ· Read (used for refresh / data-source reads)
- A Cloudflare account with R2 enabled and its
account_id. - R2 is a paid product; the account must have R2 provisioned.
terraform-cloudflare-r2-bucket/
βββ providers.tf # terraform{} + required_providers (cloudflare ~> 5.0); no provider block
βββ variables.tf # account_id + bucket{} β private by default, enum-validated location/jurisdiction/class
βββ main.tf # cloudflare_r2_bucket.this
βββ outputs.tf # id first (= name), then name, account_id, location, jurisdiction, storage_class, creation_date
βββ README.md # this document
βββ SCOPE.md # cross-module contract
βββ LICENSE # MIT
βββ .gitignore # canonical library ignore set
provider "cloudflare" {}
# export CLOUDFLARE_API_TOKEN=... (scoped to Workers R2 Storage)
module "assets" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "app-assets-prod" }
}Consumes
| Input | Type | Typical source |
|---|---|---|
account_id |
string |
the account that owns the bucket |
bucket |
object({...}) |
caller |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Bucket identifier (= name) | Workers R2 bindings, audit |
name |
Bucket name | app config, bindings |
account_id |
Account scope (echoed) | composition |
location |
Immutable location hint | audit |
jurisdiction |
Data-residency jurisdiction | compliance audit |
storage_class |
Default object storage class | audit |
creation_date |
Creation timestamp | audit |
1 Β· Minimal private bucket
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "app-assets-prod" }
}π Private by default β no public access is configured or possible through this module.
2 Β· Pin a location
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "eu-west-assets", location = "weur" }
}π
locationis immutable after first creation β choose deliberately.
3 Β· EU data residency
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "member-docs-eu", jurisdiction = "eu" }
}π
jurisdiction = "eu"guarantees objects are stored within the EU β useful for data-residency obligations.
4 Β· FedRAMP jurisdiction
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "gov-records", jurisdiction = "fedramp" }
}5 Β· Infrequent-access storage class
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "cold-archive", storage_class = "InfrequentAccess" }
}βΉοΈ
InfrequentAccesslowers storage cost for rarely-read objects.
6 Β· Location + jurisdiction + storage class
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = {
name = "eu-archive"
location = "eeur"
jurisdiction = "eu"
storage_class = "InfrequentAccess"
}
}7 Β· Many buckets from one definition (for_each)
locals {
buckets = {
assets = { name = "app-assets-prod" }
uploads = { name = "app-uploads-prod", storage_class = "Standard" }
archive = { name = "app-archive-prod", storage_class = "InfrequentAccess" }
}
}
module "buckets" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
for_each = local.buckets
account_id = var.cloudflare_account_id
bucket = each.value
}8 Β· Environment-prefixed naming
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "${var.environment}-member-documents" }
}9 Β· Reading the bucket name for application config
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "app-assets-prod" }
}
output "r2_bucket_name" { value = module.bucket.name }
output "r2_jurisdiction" { value = module.bucket.jurisdiction }10 Β· A bucket destined for a Workers binding
module "kv_bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "worker-state-prod" }
}
# module.kv_bucket.name is the bucket_name for a Workers R2 binding (see workers-script).11 Β· APAC-local bucket
module "bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "apac-assets", location = "apac" }
}12 Β· Data-residency-locked bucket set
locals {
eu_buckets = {
docs = { name = "member-docs-eu", jurisdiction = "eu", location = "weur" }
audit = { name = "audit-logs-eu", jurisdiction = "eu", location = "eeur", storage_class = "InfrequentAccess" }
}
}
module "eu_buckets" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
for_each = local.eu_buckets
account_id = var.cloudflare_account_id
bucket = each.value
}π Enforce EU residency across a whole set of buckets in one definition.
13 Β· ποΈ End-to-end composition β a bucket bound to a Worker
provider "cloudflare" {}
variable "cloudflare_account_id" { type = string }
module "state_bucket" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-r2-bucket.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
bucket = { name = "worker-state-prod", jurisdiction = "eu" }
}
module "api_worker" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-workers-script.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
script = {
name = "api"
content = file("${path.module}/worker.js")
r2_buckets = [{ binding = "STATE", bucket_name = module.state_bucket.name }] # <-- wired by name
}
}ποΈ The bucket is created privately, then bound into a Worker by
nameβ no public exposure anywhere in the chain.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
account_id |
string |
β | β | Account that owns the bucket (scope anchor). |
bucket |
object({...}) |
β | β | The bucket definition (see schema). |
Full input schema (from variables.tf)
variable "bucket" {
type = object({
name = string # REQUIRED; immutable
location = optional(string) # apac|eeur|enam|weur|wnam|oc β immutable; omit to auto-select
jurisdiction = optional(string) # default|eu|fedramp β data residency
storage_class = optional(string) # Standard|InfrequentAccess
})
# validation: each enum checked only when set (null passes)
}| Output | Description | Notes |
|---|---|---|
id |
Bucket identifier | Equals the bucket name. |
name |
Bucket name | β |
account_id |
Account scope (echoed) | For composition. |
location |
Location hint | Immutable. |
jurisdiction |
Data-residency jurisdiction | Compliance. |
storage_class |
Default object storage class | β |
creation_date |
Creation timestamp | β |
- Private by construction. There is no public-access argument on
cloudflare_r2_bucket, so this module cannot expose a bucket. Making a bucket reachable over HTTP is a separate resource (a custom/managed domain) β a deliberate, out-of-scope decision. - Immutable placement.
locationandjurisdictionare set at creation and effectively fixed. Recreating a bucket with the same name reuses the original location regardless of the new value. - Name is identity. The resource
idis the bucket name, so downstream references are stable and human-readable. - Enum validation is conditional. Each of
location/jurisdiction/storage_classis validated only when set, so the minimal call (name only) passes cleanly and lets Cloudflare choose defaults.
| Concern | Secure default | How to opt out (deliberately) |
|---|---|---|
| Public exposure | Not possible through this module (private) | Add a separate custom/managed domain resource. |
bucket.jurisdiction |
Provider default (default) |
Set eu / fedramp for data residency. |
bucket.location |
Auto-selected by Cloudflare | Pin a region explicitly (immutable). |
| Secrets | None accepted or emitted | Object contents are managed out of band, not by Terraform. |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin by immutable tag
?ref=v1.0.0, never a branch.
- β
terraform validateβ parses and runs the conditional enum validations. - β
terraform fmt -checkβ canonical formatting. - β Not offline: R2 entitlement, name uniqueness, and location availability are only checked at a real
plan/apply.
$ terraform output
account_id = "023e105f4ecef8ad9ca31a8372d0c353"
creation_date = "2026-07-18T15:04:05Z"
id = "app-assets-prod"
jurisdiction = "default"
location = "wnam"
name = "app-assets-prod"
storage_class = "Standard"
| Symptom | Cause | Fix |
|---|---|---|
bucket.location ... must be one of |
Invalid location | Use apac/eeur/enam/weur/wnam/oc, or omit. |
bucket.jurisdiction ... must be one of |
Invalid jurisdiction | Use default/eu/fedramp. |
| Location didn't change after edit | location is immutable / best-effort |
Create a new bucket with a new name for a new location. |
| Bucket not publicly reachable | By design β no public access here | Add a custom/managed domain resource deliberately. |
| Apply fails: R2 not enabled | Account lacks R2 | Enable R2 on the account. |
| Provider auth error | No/insufficient token | Configure the provider with a Workers R2 Storage token. |
- Cloudflare provider β
cloudflare_r2_bucket - Sibling module:
terraform-cloudflare-workers-script(R2 bindings) - This module's
SCOPE.mdβ the cross-module contract.
π§‘ "Infrastructure as Code should be standardized, consistent, and secure."