ShipAgent

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.local and runs the check to confirm the bucket is reachable with your token.
  • Writes the CORS rule the browser upload needs. It allows PUT and GET with Content-Type from your site URL and http://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-resources and 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_URL from the r2.dev address to a custom domain; Cloudflare describes r2.dev as 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:3000 from 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.