pithy add storage --with-prerequisites--with-prerequisites composes secrets, which storage requires — the R2 credentials are read through the registry, so storage will not compose without it.
Add auth too, if it is not already there. Every owner-scoped route reads the authenticated user, so without it those routes deny.
What lands in your repo
apps/<worker>/pithy.config.ts gains the registration. Every option has a working default, so the block is usable as written:
storage({
basePath: "/storage",
quota: { bytesPerOwner: 5 * 1024 * 1024 * 1024 },
}),The one worth setting early is the quota. Without a considered value, the first person to discover your upload endpoint decides how much R2 you buy.
apps/<worker>/wrangler.jsonc gains two bindings, in every environment stanza:
| Binding | Type | What it is |
|---|---|---|
DB | d1 | Where the object and share rows live — the same app database |
STORAGE_BUCKET | r2 | The bucket the bytes go in |
The r2_buckets entry is written with the bucket name provision will create. The bucket itself does not exist yet, and that is fine: nothing reads it until something uploads.
STORAGE_SWEEP — the daily reconciliation Workflow — is not written here. It is optional, and wrangler requires both a name and a class_name on every workflows entry with the deployed name being per project and environment. An entry short of either field does not degrade; wrangler refuses to load the config at all. It arrives with provision.
What runs
add runs that Worker’s dev migrations, creating two tables:
pithy_storage_objects · pithy_storage_shares
Those two carry the only foreign key in the kit — shares reference objects — because that one relationship is internal to the capability and cannot cross a boundary.
The five config options
| Option | Default | What it decides |
|---|---|---|
basePath | /storage | Where the object routes mount |
sharePath | /s | Where share fetches mount. Kept short, because the whole URL gets pasted into a chat window |
multipartThresholdBytes | 100 MiB | Above this, an upload is split into resumable parts. It cannot exceed R2’s single-PUT ceiling |
partSizeBytes | 64 MiB | Bytes per part. R2 allows at most 10,000 parts, so this fixes the largest object you can store |
defaultVisibility | private | Visibility for an upload that does not name one. Private, so a forgotten field can never publish a file |
pendingTtlSeconds | 86400 | How long an unfinished upload holds its quota reservation before the sweep reclaims it |
Then provision
pithy storage provision --r2-access-key-id "$R2_KEY" --r2-secret-access-key "$R2_SECRET"That creates the per-environment R2 buckets, writes the storage-r2-credentials secret, deploys the sweep worker, and writes the STORAGE_SWEEP binding into every environment stanza.
The R2 key pair is supplied, not minted. Cloudflare exposes no API for creating one, so you make it under R2 → Manage API tokens and hand it over — on the flags, or as R2_CREDENTIALS in the account config. Passing one half without the other is refused.
Each environment’s deploy needs the app DB id from that environment’s stanza and the environment’s secrets database, so pithy secrets provision has to have run first. Both are refused rather than deployed around.
pithy storage is the whole command surface, including teardown and what --storage deletes.
Check it worked
pithy doctor reports storage under the Worker’s health. Before provision, the STORAGE_BUCKET binding reads as declared with no resource behind it — which is the correct state, not a fault, until you provision.