Documentation menu

Upload files to Cloudflare R2

Cloudflare R2 speaks the S3 API with zero egress fees, which makes it a common landing spot for user uploads that later get served back to those same users. For upup it is an ordinary S3-compatible backend with two quirks worth knowing up front: the endpoint is scoped to your account ID, and the region is the literal string auto.

This page covers server mode, where @upupjs/server holds the credentials. In client mode your own endpoint signs the URLs and the browser PUTs straight to R2 — see Client Mode vs Server Mode for the split.

The config

Only the storage block differs from the AWS S3 example — keep uploadTokenSecret and the rest of the handler as they are there.

ts
const storage = {
    type: 'r2',
    bucket: process.env.R2_BUCKET!,
    region: 'auto',
    endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
}

region: 'auto' is not a placeholder to fill in. R2 has no per-region S3 hostnames — a bucket's location hint (Western North America, Europe, and so on) affects where data lives, not how you sign for it, and the S3 API expects auto regardless. Passing a real AWS region here produces a signature error.

Console setup

  1. Create the bucket. Cloudflare dashboard → R2 → Create bucket. A location hint is optional; it does not change the config above.
  2. Copy your account ID. It is on the R2 overview page and is the first label in the endpoint hostname.
  3. Create an API token. R2 → Manage R2 API Tokens → Create API token, with Object Read & Write permission. Scope it to the one bucket rather than the whole account. Cloudflare shows the access key ID, the secret access key, and the endpoint once — the secret is not retrievable later.
  4. Add a CORS policy (client mode only) under the bucket's Settings tab.

CORS (client mode only)

json
[
    {
        "AllowedOrigins": ["https://your-app.com"],
        "AllowedMethods": ["PUT", "POST", "GET", "HEAD"],
        "AllowedHeaders": ["*"],
        "ExposeHeaders": ["ETag"],
        "MaxAgeSeconds": 3000
    }
]

In server mode the browser never contacts R2, so the bucket needs no CORS policy at all. See Credentials And CORS.

R2 multipart parts must be equal-sized

R2 requires every part of a multipart upload except the last to be exactly the same size — S3 itself only enforces a 5 MiB minimum. upup's multipart strategy already slices files into uniform parts (5 MiB by default), so the default path is safe. If you override resumable.chunkSizeBytes, keep it a single fixed value rather than computing a per-file or per-part size.

Serving the files back

R2 buckets are private by default and the config above never makes them public. Two supported ways to serve an uploaded object: attach a custom domain to the bucket (the production answer, and it puts Cloudflare's cache in front of your files), or enable the r2.dev development subdomain, which is rate-limited and explicitly not meant for production traffic. Either is a Cloudflare-side setting and has no effect on the upload path.

R2 does not implement object ACLs. Anything that would be a per-object public-read on S3 is a bucket-level decision here instead.

Next steps