Storage
Store uploaded files in Cloudflare R2 and prove a file goes up, comes back and is served from its public URL.
Admins can upload images, users can upload avatars, and files are served from a public URL when this check passes:
pnpm tsx scripts/check-storage.ts
Its last line must be Storage: all checks passed.
You
Create one bucket and one token, turn on public access, and paste five values.
| Value | Where you get it |
|---|---|
R2_BUCKET_NAME |
Cloudflare dashboard → R2 Object Storage → Create bucket → a name (for example mysite-uploads), location Automatic, storage class Standard → Create bucket. The first time, Cloudflare may ask you to activate R2 (it has a free tier) |
R2_ACCOUNT_ID |
R2 Object Storage overview → Account ID. It is also the first part of the bucket's S3 endpoint, https://<ACCOUNT_ID>.r2.cloudflarestorage.com |
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY |
R2 Object Storage → Manage API tokens → Create API token → Permissions Object Read & Write → Specify bucket: this bucket only → Create. Copy Access Key ID and Secret Access Key; the secret is shown once |
R2_PUBLIC_URL |
the bucket → Settings → Public access: either R2.dev subdomain → Allow (gives https://pub-….r2.dev, for testing) or Custom Domains → Connect Domain (for example https://files.yourdomain.com; the domain must be on Cloudflare; use this for production) |
R2_PUBLIC_URL format: https://host, nothing after it. No trailing slash, no path, not http://. Good: https://files.yourdomain.com. Bad: https://files.yourdomain.com/.
Use a bucket of its own for uploads. When you deploy to Cloudflare, the new-site script creates a second bucket named <site>-inc-cache. That one holds the Workers page cache, not user files. Never point R2_BUCKET_NAME at it.
The Cloudflare click paths are as of late 2026 (check the dashboard, menus move).
The bucket's Settings tab shows the bucket name and, in the S3 API address, the account ID:
When you create the API token, pick Object Read & Write:
For production, connect a custom domain under the bucket's Settings → Custom Domains:
Agent
- Writes the five values into
.env.localand runs the check to confirm the bucket is reachable with your token. - Writes the CORS rule the browser upload needs. It allows
PUTandGETwithContent-Typefrom your site URL andhttp://localhost:3000, and keeps any rules already there. - If the token is not allowed to write CORS, it prints the rule as JSON. You: paste it at the bucket → Settings → CORS Policy → Add CORS policy, save, and say "done".
- Sends the same preflight request a browser sends before an upload, so the CORS rule is proven whichever way it was written.
- Uploads a 1 MB test file through the app's own upload code, downloads it back and compares every byte, fetches it through
R2_PUBLIC_URL, then deletes it. - Asks you to try it in the app: sign in as admin, open
/dashboard/admin/r2-resourcesand upload an image. It must appear in the list and open from its public URL. An avatar upload under dashboard → Settings tests the server-side path.
What a pass looks like
✓ R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL are set
✓ bucket mysite-uploads reachable with R2_ACCESS_KEY_ID
✓ bucket CORS allows PUT and GET with Content-Type from http://localhost:3000
✓ browser preflight for PUT from http://localhost:3000 allowed
✓ 1 MB uploaded through a presigned PUT from lib/cloudflare/r2 (<key>)
✓ presigned GET returned the same 1048576 bytes
✓ public URL serves the file (https://pub-….r2.dev)
✓ test object deleted again
Storage: all checks passed
When the agent writes the CORS rule itself, the CORS line reads ✓ CORS rule written for <origins> (existing rules kept).
When it fails
| The check says | What it means | Fix |
|---|---|---|
R2_PUBLIC_URL must be https://host … |
trailing slash, a path, or http:// |
write it as https://host |
bucket … not reachable: NoSuchBucket |
name typo, or the bucket is in another account | copy the exact bucket name |
… InvalidAccessKeyId or SignatureDoesNotMatch |
key id and secret from different tokens, or a general Cloudflare API token instead of the R2 keys | create a new R2 API token and paste both values from it |
… AccessDenied |
the token is for another bucket, or read-only | a token with Object Read & Write on this bucket |
… EPROTO, ENOTFOUND or a TLS error |
R2_ACCOUNT_ID is wrong (it forms the address) |
copy the 32-character Account ID |
browser preflight … refused |
no CORS rule for that origin yet | the agent writes the rule, or you paste the JSON; wait a minute and rerun |
presigned PUT answered 403 |
the machine's clock is off, or the token cannot write | check the clock; use Object Read & Write |
public URL … answered 404/401 |
public access is off, or R2_PUBLIC_URL points at another bucket |
turn on the r2.dev subdomain or connect the custom domain on this bucket |
| upload works in the check, fails in the browser | the site runs on an origin the CORS rule does not list (www. vs bare domain, another port) |
the agent adds that origin to the rule |
Going live
- The five
R2_*values go into the production env, then the site is redeployed. - Switch
R2_PUBLIC_URLfrom ther2.devaddress to a custom domain; Cloudflare describesr2.devas meant for development. Do this before real users upload: files already saved keep their old URLs in the database. - The agent reruns the check with your production domain as an extra origin, so the CORS rule covers it. You can drop
http://localhost:3000from the rule if you want production only. - Storage is plain S3 under the hood. Another S3-compatible store (AWS S3, Backblaze B2, MinIO) needs one line changed in
lib/cloudflare/r2-client.ts.