Four types
Which backend each type uses is settled in the reference; standing the chosen ones up is provisioning.
image · video · audio · document. The type is the discriminator on every record.
Where the bytes go
| Type | Options | Default |
|---|---|---|
image | r2 or cf-images | Configurable |
video | r2 or cf-stream | Configurable |
audio | r2 always | — |
document | r2 always | — |
Only two of the four are a decision, and both are a decision about what the product does for you rather than about where a file sits.
Images. R2 stores the bytes you uploaded and hands them back. Cloudflare Images stores an original and serves derived variants — resized, reformatted, optimized — from one upload. If you are building your own resize pipeline on top of R2, you have chosen the wrong option.
Video. R2 stores a file. Cloudflare Stream ingests it, transcodes it, and serves adaptive HLS. The enrichment path for video reads a Stream HLS audio rendition, which is a capability R2 does not have — so this choice reaches further than delivery.
The R2 key is namespaced by type
media/<type>/<id>.
The media/ prefix means a shared bucket never collides with your own objects, and the type segment partitions the space so a bucket listing is readable by a person.
This is also where the storage seam pays off: @pithy-sh/storage derives obj/<uuid> and media passes media/<type>/<id> to the same store. The store takes an explicit key and moves bytes; it knows nothing about how the key was chosen. Which is precisely what lets media import the seam without inheriting storage’s opaque-key decision — media’s keys are meant to be legible, and storage’s are meant not to be.
Three lifecycle states
Every upload is one row in a database you hold, whichever backend served the bytes.
pending when the upload URL is minted. stored when the upload is finalized. failed.
The finalize step is not a formality — it is what turns a minted URL into a record something will serve, and what the enrichment Workflows key off.
Documents extract from three extensions
pdf, doc, docx. Extraction is only dispatched for those, because those are what the converter can read.
A document outside that set is stored perfectly well. It simply has no text to derive.
Two credentials, and media reads one of them
media-storage-credentials carries the Images and Stream token that media mints direct-upload URLs with. This is media’s own.
media-r2-credentials carries the R2 key pair and bucket, and is read by storage’s object store. Media declares the name and never touches the values.
That split follows the same rule as everything else in the kit: the capability that reads a value is the capability that owns it. Media naming a secret it never opens is a declaration, not an access.
One bucket per project, per environment
acme-staging-media, acme-prod-media.
A shared bucket would mean staging writes into the bucket prod reads, and a staging teardown deletes prod’s media. Buckets are free — you pay for bytes and operations — so sharing one buys nothing.
The project segment carries ownership. R2’s namespace is flat and account-wide, and provisioning reuses a bucket it finds by name. Without the project prefix, a second Pithy project in the same account adopts this one’s bucket instead of creating its own, and either teardown takes both projects’ media.
Records can live in KV instead of D1
When configured for KV, provisioning stands up a MEDIA namespace — again per project and per environment, because KV titles are account-wide too and reuse keys on the title.
The bucket and the namespace happen to want the same string today, and they are asked for separately through the naming facade: a KV title may run to 512 characters where an R2 bucket stops at 63, and one number standing in for both is how a name gets silently truncated in the namespace with the looser rule.