Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸͺ£ Cloudflare R2 Bucket Terraform Module

Manage a private-by-default Cloudflare R2 object-storage bucket β€” targeting cloudflare/cloudflare ~> 5.0.

Terraform Provider Module Version Type Resources

🧩 Overview

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 Standard or InfrequentAccess for 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.

❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!

πŸ—ΊοΈ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

Resource inventory

Resource Name Cardinality Role
cloudflare_r2_bucket this 1 (keystone) A private R2 object-storage bucket.

βœ… Provider / Versions

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):

  • πŸ”’ location is 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.
  • ℹ️ id equals the bucket name. There is no separate opaque identifier.
  • ℹ️ storage_class (Standard/InfrequentAccess) sets the default class for newly uploaded objects.

πŸ”‘ Required Cloudflare API Token Permissions

  • Workers R2 Storage Β· Write (create and manage buckets)
  • Workers R2 Storage Β· Read (used for refresh / data-source reads)

Cloudflare Prerequisites

  • A Cloudflare account with R2 enabled and its account_id.
  • R2 is a paid product; the account must have R2 provisioned.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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" }
}

πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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" }
}

πŸ”’ location is 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" }
}

ℹ️ InfrequentAccess lowers 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.

πŸ“₯ Inputs

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)
}

🧾 Outputs

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 β€”

🧠 Architecture Notes

  • 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. location and jurisdiction are 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 id is the bucket name, so downstream references are stable and human-readable.
  • Enum validation is conditional. Each of location/jurisdiction/storage_class is validated only when set, so the minimal call (name only) passes cleanly and lets Cloudflare choose defaults.

🧱 Design Principles

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.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin by immutable tag ?ref=v1.0.0, never a branch.

πŸ§ͺ Testing

  • βœ… 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.

πŸ’¬ Example Output

$ 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"

πŸ” Troubleshooting

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.

πŸ”— Related Docs

  • 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."

About

Terraform module: terraform-cloudflare-r2-bucket

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages