Dedicated storage per workspace

Give a workspace its own R2 bucket — how buckets are pooled and provisioned, why there is no per-workspace key, and what happens on archive.

#Dedicated storage per workspace

A workspace gets a bucket of its own — a separate keyspace, its own lifecycle rules, and storage you can bill or delete as one unit. That is the default: a workspace created without saying anything about storage asks for its own bucket, and is refused rather than created if the deployment cannot supply one.

The alternative is still there, and has to be asked for by name: send "storage": "shared" and the workspace's files live in the instance's one R2 bucket, kept apart by project-scoped queries. Nothing reaches that state by silence, because a workspace you believe is isolated and is not is worse than one you know is shared.

#There is no per-workspace key, and that is the point

A dedicated bucket here is a name, not a credential. Your deployment's R2 credentials already address every bucket in the account, so pointing a workspace at its own bucket introduces no new secret.

That matters because the usual design — a stored access key per workspace — is also the usual way this feature loses data. Every stored key is encrypted under BETTER_AUTH_SECRET; a key you cannot decrypt is a workspace you cannot serve. A credential that does not exist cannot be lost.

What this does not give you: credential isolation. The instance credential opens every workspace's bucket, so this is a blast radius on data, not on keys. If you need a leaked credential to be unable to read other workspaces, that is a different feature — and it would require an account-level token in the worker that can mint tokens, which is a more dangerous credential than the ones it would protect.

#Creating a workspace with it

storage may be omitted; "dedicated" is what it defaults to.

curl -X POST https://your-api/api/v1/system/tenants \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name": "Northwind"}'

The response tells you which bucket the workspace got and where it came from:

{
  "tenant": { "id": "01J…", "name": "Northwind", "slug": "northwind" },
  "storage": { "mode": "dedicated", "requested": "dedicated",
               "bucket": "acme-t-northwind", "source": "created" }
}

mode is what the workspace actually got, and it can never disagree with requested. A request for dedicated that cannot be met is a 409 and no workspace, not a 201 with a note in a field you might not read.

#Where the bucket comes from

The pool, first. npm run setup -- --buckets 4 creates extra buckets at deploy time and stamps their names into the worker, exactly as the D1 pool works. Claiming one is instant and needs no API token.

Created on demand, second. If the pool is dry and CLOUDFLARE_API_TOKEN has R2 write scope, a bucket named <instance>-t-<slug> is created. Note that R2 and D1 are separate scopes on the same token — a token that provisions databases happily may still be refused here, and the error says so.

Otherwise the workspace is not created at all. A 409 with blockers naming what to fix:

{ "error": "storage_unavailable", "blockers": ["…"] }

This holds on both routes. A deployment that could never allocate is refused before anything is written; an allocation that fails part-way — the on-demand route, which cannot be tested without attempting it — has the half-created workspace removed again, so a 409 always means no workspace.

Refusing is deliberate, and is ADR 0007's rule that provisioning refuses rather than degrades. Creating the workspace on shared storage would answer a different question than the one you asked, and you would have no reason to look again.

Check availability before you offer the choice — GET /api/v1/system/tenants returns a storage block with the pool, what is claimed, what is free, and the same blockers text the 409 would carry.

#What you need configured

R2_ENDPOINT, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY must be set on the gateway worker. Only the instance's own bucket has a Worker binding; every other bucket is reachable only through the S3 API. Without these a dedicated bucket would be a workspace whose every upload fails, so allocation is refused rather than half-done.

#What happens to existing files

Nothing moves. Each file records the bucket it was written to, and downloads read that record rather than the workspace's current setting — the same mechanism that already makes re-pointing a file container safe. Turning dedicated storage on routes new uploads only.

A file container that names its own bucket still wins over the workspace default, so files somebody deliberately placed elsewhere stay where they were put.

#Archiving a workspace

Archiving is soft and reversible, so the bucket is kept and so is the claim. The DELETE response names it:

{ "ok": true, "status": "archiving", "retainedBucket": "acme-t-northwind" }

Two reasons it works this way. Returning the bucket to the pool would hand a later workspace a bucket full of an earlier workspace's files. And deleting the bucket would be irreversible where the archive is not — R2 refuses to delete a non-empty bucket anyway, so the attempt would fail loudest for exactly the workspaces whose data mattered most.

Until you reclaim it, that bucket keeps appearing on your R2 bill, which is why the archive response names it.

#Reclaiming a bucket

The deliberate, destructive counterpart — for a workspace you are sure is gone:

curl -X DELETE https://your-api/api/v1/system/tenants/$ID/storage \
  -H "Authorization: Bearer $TOKEN"

This deletes the bucket and then releases the claim, in that order. Releasing the claim alone would return a bucket still full of files to the pool for the next workspace to inherit, so the two are tied together — and because R2 refuses to delete a non-empty bucket, "there is still data here" becomes an automatic refusal rather than a check anyone has to remember:

{ "error": "validation_error",
  "message": "Could not delete bucket \"acme-t-northwind\": … not empty …" }

If you see that, the workspace's files are still there and nothing was changed. Empty the bucket first if you genuinely mean to discard them.

Two refusals to expect: the workspace must already be archived (422 if it is still active — archive first, since that step is reversible and this one is not), and it must actually have a dedicated bucket (422 if it is on shared storage, so "nothing to reclaim" never looks like "reclaimed" to a script cleaning up many workspaces).

#Finding an orphaned bucket

Every allocated bucket carries _sveltesync/tenant.json naming the workspace it belongs to. If you find a bucket in the Cloudflare dashboard and cannot place it, read that object — it records the workspace id, the slug, and the instance that created it.