If your website stores images in a bucket, you probably thought of Amazon S3. Cloudflare R2 does the same thing, with the same API, and is billed differently. For website images, that difference weighs more than it seems.
The difference that matters: data transfer out
Every time someone opens your site, their browser downloads the images from your bucket. That download is outbound traffic. S3 charges for it. R2, according to Cloudflare's documentation, does not charge for data transferred out of the bucket. What it charges is storage and the number of operations (those that modify data and those that read it), and there is a monthly free allowance for standard storage.
For a site with many images and visits, that is the part that changes the bill the most. For a small one, with few images and little traffic, the difference may be minimal. Check each provider's current pricing before deciding: it changes.
What they share
R2 can be used with the S3 API. In practice, libraries and plugins that talk to S3 work when pointed at another endpoint. That lets us use Payload's official S3 storage plugin on this site, the same one you would use with Amazon.
What changes
- Custom domain. R2 lets you serve the bucket from your own domain, something like
media.yourdomain.com. That option requires the domain to be added as a zone in the same Cloudflare account as the bucket. Ther2.devURL is rate-limited and for development only; use your domain in production. - Ecosystem. S3 has many more services around it: notifications, very fine-grained lifecycle policies, integration with the rest of AWS. If your application already lives on AWS, S3 in the same region is the most natural choice.
- Cloudflare. R2 integrates with Workers and with Cloudflare's network, which gives good performance for public files.
How we connect it to Payload
With the @payloadcms/storage-s3 plugin. This is this site's configuration, abridged:
s3Storage({
collections: { media: publicFiles },
bucket: process.env.S3_BUCKET ?? '',
config: {
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
},
},
}),The endpoint decides who you talk to. In production it points to R2; locally, to MinIO, an S3 server in a Docker container. The code doesn't change.
So that the browser requests images straight from the bucket, and not through Payload, we define how the public URL is built:
const publicFiles = process.env.S3_PUBLIC_URL
? {
disablePayloadAccessControl: true as const,
generateFileURL: ({ filename, prefix }) =>
[process.env.S3_PUBLIC_URL?.replace(/\/$/, ''), prefix, filename].filter(Boolean).join('/'),
}
: true;With S3_PUBLIC_URL defined (your R2 domain), Payload returns that URL. Without it, as happens locally, it serves the files itself. For credentials, R2 gives its own API key pair, created in the Cloudflare dashboard.
A detail that bit us
If you upload images from a script, don't reuse the same context between payload.create calls. The S3 plugin keeps state there, and the second upload in the process went missing. Passing a fresh context on each call fixed it. We tell it along with other stumbles in how codegea.com is built.
When to choose each
- R2: sites with many images or downloads, where data transfer out would weigh, and projects where you already use Cloudflare for DNS.
- S3: if your application already runs on AWS, or you need Amazon ecosystem features that R2 doesn't have.
And after that infrastructure decision, the next step is usually putting the rest of the site behind Cloudflare. We cover it in Cloudflare in front of your website.
Written by
Derian Pinto
Founder & Full Stack Developer
Founder of Codegea. Builds custom websites, stores and platforms, and integrates and supports Arc XP for media companies.