Skip to content

Cloudflare R2 bucket per tenant pays off for residency and deletion

Infra · November 9, 2026 · 9 min read

Loops winding across a grid of tiles in black, white and grey

Per-tenant storage on Cloudflare R2 is the choice between giving every customer of a SaaS its own bucket and giving every customer a key prefix inside a shared bucket. It decides what happens at signup, how you bill for storage, where a customer's files physically live and how you show they are gone when the customer leaves.

The question arrives the day a product lets users upload files. The first instinct is often one bucket per customer, created through the API when the account opens. That works, and R2 now makes it cheap up to a point. It also puts a Cloudflare API call, a naming rule and a rate limit inside your signup flow. Below, the two models are measured against R2's published limits as of November 2026, with the steps to automate either one.

Which model should a SaaS start with?

Start with a prefix per tenant in one shared bucket per data jurisdiction. Give a tenant its own bucket when a contract asks for something only a bucket provides: files kept inside the EU, a storage line on the invoice that comes from Cloudflare's own figures, a custom domain per customer, or a deletion you can show as a bucket that no longer exists. Most products need the first model for every customer and the second for a handful.

The two models side by side

  • Signup. Prefix: nothing to create, the first upload writes under tenants/<tenant-id>/. Bucket: one CreateBucket call, then CORS and lifecycle configuration.
  • Ceiling. Prefix: unlimited objects per bucket. Bucket: 100,000 included per account, then $0.10 per bucket per month.
  • Isolation. Prefix: enforced by your code and by credentials scoped to the prefix. Bucket: enforced by the bucket boundary.
  • Usage per customer. Prefix: your own ledger. Bucket: read from R2's storage analytics by bucket name.
  • Data location. Prefix: set once per shared bucket. Bucket: set per customer at creation, fixed afterwards.
  • Offboarding. Prefix: delete every key under the prefix. Bucket: empty the bucket, then delete it.

What does R2 allow per account in 2026?

Four numbers from Cloudflare's documentation shape the decision.

  • Buckets. There is no hard cap, with a default limit of 100,000 buckets per account. Each bucket above 100,000 costs $0.10 a month (R2 limits, R2 pricing). At 250,000 tenants with a bucket each, that line alone is $15,000 a month before a single byte is stored.
  • REST API rate. The Cloudflare REST API accepts 1,200 requests per five minutes across all R2 operations on an account. Go over it and every API call is blocked for the next five minutes, dashboard included (Cloudflare API rate limits). A signup spike that creates buckets through the REST API can lock you out of your own dashboard.
  • Bucket management. Creating, deleting, listing and configuring are capped at 50 operations per second per bucket. Object reads and writes do not count toward it.
  • API tokens. An account holds at most 500 account API tokens. A long-lived S3 key per customer stops working as a design at customer 501.

Bucket names add one constraint: lowercase letters, digits and hyphens, 3 to 63 characters, no hyphen at either end (Create new buckets). A UUID tenant id fits at 36 characters. A company name needs cleaning first, and two companies can clean down to the same string.

Where a prefix per tenant wins

Signup stays inside your database

Nothing is created at R2 when an account opens. The first upload writes to tenants/<tenant-id>/, and the prefix exists from that moment. No external call can fail halfway through onboarding, so there is no half-created customer to clean up.

No ceiling and no per-bucket fee

A bucket holds unlimited objects. A product with a free plan and a long tail of dormant accounts pays only for the bytes those accounts store, at $0.015 per GB-month on Standard storage, with egress free in both models.

Scoped access still works

Isolation moves from the bucket boundary to the credential. R2's temporary credentials take a parent token, exactly one bucket, a permission scope and an optional list of prefixes, and expire after at most 604,800 seconds, seven days (Temporary credentials). A credential scoped to tenants/acme/ cannot read tenants/globex/. For a single browser upload or download, a presigned URL grants one operation on one object, valid from one second to seven days (Presigned URLs).

POST /accounts/{account_id}/r2/temp-access-credentials
{
  "bucket": "tenants-default",
  "parentAccessKeyId": "<parent key id>",
  "permission": "object-read-write",
  "prefixes": ["tenants/acme/"],
  "ttlSeconds": 900
}

Each call to that endpoint spends one of the 1,200 requests in the five-minute budget. For many short-lived credentials, Cloudflare documents a local path: your backend signs a JWT with the parent secret and derives the credential itself, with no call to Cloudflare at all. Revoking the parent token stops every credential derived from it at once.

Where a bucket per tenant wins

Data residency per customer

Jurisdiction is a property of the bucket. R2 offers eu, us and fedramp, guarantees that objects stay inside the chosen one, and does not let you change it after creation (Data location). With prefixes, EU customers share an EU bucket. With buckets, each customer gets the jurisdiction their contract names. Either way, plan one S3 client per jurisdiction: a jurisdictional bucket answers only on its own endpoint, https://<ACCOUNT_ID>.eu.r2.cloudflarestorage.com for the EU (R2 authentication).

Usage per customer from Cloudflare's figures

R2's storage analytics report payload size and object count by bucket name, through the GraphQL Analytics API, over windows of up to 31 days (R2 metrics and analytics). With a bucket per customer, the storage line on an invoice comes from the same numbers Cloudflare bills you on. With prefixes, you keep the ledger: record each object's size when it is written and subtract it when it is deleted.

Configuration per customer

CORS rules, lifecycle rules, event notifications and up to 100 custom domains are set on the bucket. A customer who serves files from their own domain, or who needs a retention period nobody else has, is simpler as a bucket of their own.

A deletion you can show

Offboarding costs about the same in both models, because R2 deletes only empty buckets. Every object goes first, through the dashboard's Empty action, a client loop, or a lifecycle rule that expires everything after one day (Delete buckets). DeleteObject and DeleteBucket are free operations, and listing the keys is a Class A operation at $4.50 per million. The difference is the record you keep. "Bucket deleted on this date" answers a customer's deletion request in one line. A prefix leaves you a log of deleted keys.

How do you automate R2 bucket setup when a user signs up?

When a customer does need a bucket, run its creation as a background job, never as a step inside the signup request.

  1. Derive the name from the tenant id. For example t- followed by the tenant's UUID, lowercase. Store it on the tenant row before calling Cloudflare, so a retry targets the same name.
  2. Create it through the S3-compatible API. CreateBucket, with a token holding Admin Read & Write, on the endpoint of the jurisdiction the customer needs. Cloudflare sets the 1,200-request cap on its REST API and points high-volume work to the S3 API. Each PutBucket is billed as a Class A operation.
  3. Apply the configuration in the same job. CORS for the app's origins and any lifecycle rule the plan includes. Both are Class A operations too.
  4. Queue and throttle. Run the jobs through a queue at a fixed rate, and on a 429 wait for the number of seconds in the retry-after header before the next attempt.
  5. Hand out access per session. Keep one parent token and mint temporary credentials bound to the customer's bucket. One token per customer runs into the 500-token limit.
  6. Mark the tenant ready. Keep uploads off until the job has saved the bucket name and a ready flag on the tenant row.

Names derived from a tenant id also avoid a quiet trap. R2 honours a Location Hint only the first time a bucket name is created: delete a bucket, recreate it under the same name, and it comes back in its original location.

What we use and why

On the multi-tenant products we build, the default is one shared bucket per jurisdiction, for example tenants-default and tenants-eu, with a prefix per customer and the jurisdiction stored on the tenant row. The backend signs presigned URLs for uploads and downloads, and writes each object's size to a usage table at upload time. A dedicated bucket is reserved for customers who need their own domain, their own retention rule or a deletion they can audit, and the job above creates it.

The trade-off we accept is that isolation lives in code. One missing tenant check in a route that signs URLs exposes another customer's files. So every signing call reads the tenant from the session, never from the request body, and a test tries to sign a key under another tenant's prefix and expects a refusal. The same discipline applies to database rows, covered in Supabase RLS at scale, and the case for building multi-tenant from the start is in Multi-tenant from day one. If R2 itself is still an open question against S3 or Supabase Storage, start with our comparison of the three.

Sources

Frequently asked questions

Rather have it done for you?

We design and build this kind of work for clients. Tell us what you need.