R2 is S3-compatible storage with no egress fees. That one constraint eliminates a common budget leak. The rest of the architecture follows from how you choose to read and write objects: through a Workers binding, through the S3 API with presigned URLs, or via a public custom domain. Each path has distinct cost, security, and latency profiles.
██████╗██╗ ██████╗ ██╗ ██╗██████╗ ███████╗██╗ █████╗ ██████╗ ███████╗ ██╔════╝██║ ██╔═══██╗██║ ██║██╔══██╗██╔════╝██║ ██╔══██╗██╔══██╗██╔════╝ ██║ ██║ ██║ ██║██║ ██║██║ ██║█████╗ ██║ ███████║██████╔╝█████╗ ██║ ██║ ██║ ██║██║ ██║██║ ██║██╔══╝ ██║ ██╔══██║██╔══██╗██╔══╝ ╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║ ███████╗██║ ██║██║ ██║███████╗ ╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ██████╗ ██████╗ ███████╗████████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗ ██╔══██╗╚════██╗ ██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝ ██╔════╝ ██████╔╝ █████╔╝ ███████╗ ██║ ██║ ██║██████╔╝███████║██║ ███╗█████╗ ██╔══██╗██╔═══╝ ╚════██║ ██║ ██║ ██║██╔══██╗██╔══██║██║ ██║██╔══╝ ██║ ██║███████╗ ███████║ ██║ ╚██████╔╝██║ ██║██║ ██║╚██████╔╝███████╗ ╚═╝ ╚═╝╚══════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
R2 is a globally distributed object store that speaks the AWS S3 API. You configure it with AWS SDK v3,
use the same PutObject / GetObject calls,
and presign URLs with the same @aws-sdk/s3-request-presigner package.
The difference is the endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
instead of an AWS regional endpoint. Region is always "auto".
R2 ignores the value but the SDK requires a non-empty string.
The architectural bet R2 makes is this: S3 compatibility lets existing tooling work immediately, zero egress lets you serve large files without a separate CDN budget, and the Workers binding lets objects flow through Cloudflare's network without an S3 client at all. Those three together cover most media-storage use cases without touching AWS.
"auto"env.MY_BUCKET.get(key)r2.dev subdomain available for dev onlyR2 charges on three axes: storage (GB-month), Class A operations (mutations), and Class B operations (reads). Egress is free on both storage classes. That is the defining number compared to S3, where egress from us-east-1 to the internet costs $0.09/GB after the first 100 GB.
| Metric | Standard Storage | Infrequent Access | Free Tier |
|---|---|---|---|
| Storage | $0.015 / GB-month | $0.010 / GB-month | 10 GB-month / month |
| Class A Operations | $4.50 / million | $9.00 / million | 1M requests / month |
| Class B Operations | $0.36 / million | $0.90 / million | 10M requests / month |
| Data Retrieval | None | $0.01 / GB | N/A (Standard only) |
| Egress | Free | Free | Free |
| Deletes | Free | Free | Free |
Class A operations mutate state. PutObject,
CopyObject,
ListBuckets,
ListObjects,
CreateMultipartUpload,
UploadPart,
PutBucketCors all fall here.
Class A costs $4.50/million on Standard storage. If your app does 10 million user uploads per month,
that is $45 in operation costs before storage.
Class B operations read existing state. GetObject,
HeadObject,
HeadBucket,
GetBucketCors.
Class B is $0.36/million. 100 million downloads costs $36.
DeleteObject and AbortMultipartUpload
are free. Never hesitate to delete.
Cloudflare rounds up to the next billing unit. 1,000,001 Class A operations bills as 2,000,000. 1.1 GB-month of storage bills as 2 GB-month. Storage is calculated from the peak per day averaged over 30 days, not total bytes written. Infrequent Access has a 30-day minimum storage duration: deleting an object on day 5 still charges you for 30 days of storage. Use Standard storage for anything with a short lifecycle.
Lower per-GB cost ($0.01 vs $0.015) but higher operation costs and a $0.01/GB data retrieval fee. The free tier applies to Standard storage only. Infrequent Access is suitable for backups, archives, and large model artifacts that get read infrequently. For user media that gets downloaded on every page load, Standard storage is almost always cheaper at scale.
Presigned URLs let you grant time-limited access to a single object without exposing credentials. The URL encodes the authorization using SigV4. Anyone with the URL can perform the specified operation until it expires. Maximum expiry is 7 days (604,800 seconds). Minimum is 1 second.
There are two common flows: presigned PUT for direct browser uploads, and presigned GET for private download links. Both are generated server-side, with no communication to R2 at signing time. Only your credentials and an SigV4 implementation are needed.
Browser sends file metadata (name, size, MIME type) to your server route.
Server validates user session, mints a 2-minute PUT URL with the matching ContentType. Key is namespaced by userId.
Browser fetches PUT to the presigned URL. File goes directly from browser to R2. Your server never handles the bytes.
Client tells server the upload completed. Server stores the R2 key in the database for later retrieval.
// Server route: generate presigned PUT URL
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const key = `${userId}/${crypto.randomUUID()}-${sanitizedFilename}`;
const url = await getSignedUrl(
r2,
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
ContentType: validatedContentType, // must match exactly on upload
}),
{ expiresIn: 120 }, // seconds, not milliseconds
);
The presigned URL is signed over the Content-Type header value.
If the browser's fetch call sends a different Content-Type than was
passed to PutObjectCommand, R2 returns
SignatureDoesNotMatch.
Always pass ContentType to the command, validate it server-side
against an allowlist before signing, and pass the identical value in the browser fetch call.
The bucket stays private. To serve a file, the user hits a server route that verifies their session, checks they own the object (key starts with their userId or exists in the DB with their user_id), then mints a short-lived GET URL. The client is redirected to R2. No proxying.
Keep expiry short: 5 minutes (300 seconds) is enough for a redirect. Re-mint on demand. Do not cache presigned GET URLs longer than their TTL. A leaked URL expires naturally; a cached-forever URL does not.
// Server: presigned GET behind session check
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export async function GET(req: Request) {
const session = await auth();
if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
const key = getKeyFromRequest(req);
// ownership check before signing
if (!key.startsWith(session.userId + "/")) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const url = await getSignedUrl(
r2,
new GetObjectCommand({ Bucket: R2_BUCKET, Key: key }),
{ expiresIn: 300 },
);
return Response.redirect(url, 302);
}
Browser-to-R2 presigned PUTs require a CORS rule on the bucket. Without it, the browser's preflight OPTIONS request is blocked by the same-origin policy before it reaches R2. Set this once in the Cloudflare Dashboard (R2 bucket, Settings, CORS) or via the S3 API.
// R2 CORS configuration (JSON format for Dashboard or API)
[
{
"AllowedOrigins": ["https://your-app.vercel.app"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3600
}
]
Use specific origins in AllowedOrigins. Do not use
"*" in production. For Vercel preview deployments,
you either need to add preview URL patterns or generate URLs only from your server (which has the credentials),
then let the browser PUT to R2 from the preview domain. R2 checks the Origin header on preflight.
If it does not match any allowed origin, the preflight fails.
Only allow PUT in AllowedMethods
for an upload bucket. There is no reason for a browser to GET or DELETE directly from R2 if you have
a server-gated download flow.
When your code runs inside a Cloudflare Worker, you can bind an R2 bucket directly without
an S3 client or credentials. The binding is declared in wrangler.toml
and injected as env.MY_BUCKET. This is the lower-latency,
zero-credential path. It only works in a Worker context. Next.js server routes, Netlify Functions,
and Node.js scripts are not Worker contexts; they use the S3 API.
# wrangler.toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-media-bucket"
---
// Worker handler
export default {
async fetch(req: Request, env: Env) {
const object = await env.MY_BUCKET.get("path/to/file.png");
if (!object) return new Response("Not Found", { status: 404 });
return new Response(object.body, {
headers: { "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream" },
});
},
};
Buckets are private by default. Public access is opt-in. Two exposure paths exist. Choose based on what you need the Cloudflare network to do between the client and storage.
Connect a zone you control. R2 sits behind Cloudflare's network. This unlocks WAF custom rules, Smart Tiered Cache, Cloudflare Access for authenticated buckets, and Bot Management. Cache Everything page rules let you cache all file types, not just the defaults. Use this for public assets: static site files, product images, podcast audio, ML artifacts.
Smart Tiered Cache places a single upper-tier data center near your R2 bucket and serves subsequent requests from cache. First-request latency hits R2; subsequent requests hit edge cache.
Cloudflare provides a *.r2.dev subdomain for quick testing.
No WAF, no cache, no access controls. Do not put user data here. Do not use in production.
It exists to let you verify objects are accessible before wiring up a custom domain.
Disable it before adding any production data to the bucket.
For user uploads (profile photos, KYC documents, receipts), keep the bucket private. Serve every file through a server route that checks the session and mints a presigned GET. The bucket never has public access enabled. R2 never serves a file without a valid signature. Access expires when the URL does.
| Pattern | Use Case | Cloudflare Features | Access Control |
|---|---|---|---|
| Custom domain | Public static assets, CDN | WAF, Cache, Access, Bot Management | Cloudflare Access or WAF Token Auth |
| r2.dev | Local dev / testing only | None | None (fully open) |
| Presigned GET | Private user media | N/A (goes direct to R2) | Server session check + SigV4 |
| Workers binding | Gated delivery via Worker | Worker logic runs first | Worker-level auth (any logic) |
R2 has no ACLs on individual objects. Access control happens at the bucket level (public/private) and at the API token level. Get both right from the start.
Namespace all user-uploaded objects by userId:
userId/uuid-filename.ext.
Before minting any presigned URL, verify the requested key starts with the session user's ID.
This prevents one user from reading or overwriting another user's files with a guessed key.
R2 does not enforce this. Your server does. The bucket is a flat namespace. Prefixes are just key conventions.
Create per-bucket, least-privilege tokens in the R2 Dashboard. A download-only service gets a read-only token. An upload route gets read-write on one bucket only. R2 encrypts objects at rest automatically. Token scoping limits the blast radius of a leak.
Store tokens in a secrets manager (Hazina, AWS Secrets Manager, Infisical). Never commit them. Never log them in request traces.
For callers that use a standard S3 client and need multiple operations in one scoped session, R2 supports temporary credentials. These scope to a bucket, a set of permitted operations, and optionally specific key prefixes. Different from presigned URLs: presigned URLs grant access to one object for one operation. Temporary credentials grant an S3-client session.
R2 is not full S3. Common operations work. Some do not. Check this list before migrating a workload that relies on edge-case S3 features.
/api/tokens/temporary
If your existing S3 code only uses PutObject,
GetObject,
DeleteObject,
ListObjectsV2,
HeadObject,
and presigned URLs, R2 is a drop-in. Change the endpoint and credentials; everything else stays.
If you rely on ACLs, KMS-managed encryption, or object versioning, those require a migration plan.
The comparison that matters is R2 vs S3 for a media-heavy app. Assume 1 TB stored, 100 GB egressed per day (3 TB/month), 5 million user uploads/month (Class A), 50 million downloads/month (Class B).
Free tier (10 GB storage, 1M Class A, 10M Class B) covers a small app with no cost. The free tier applies only to Standard storage. Do not use Infrequent Access expecting free-tier coverage.
Operation count is the variable to watch. ListObjects calls are Class A. If you paginate a large bucket frequently (crawlers, admin dashboards), each page is a Class A operation at $4.50/million. Design list-heavy flows to run infrequently or cache results.
Egress is free on R2 for both Standard and Infrequent Access. For any workload that reads data out to the internet frequently, that single number changes the cost model compared to S3. Check your current S3 egress bill before dismissing the migration.
Region must be "auto" in the S3 client config.
R2 ignores it but the AWS SDK throws without it. This is a one-line gotcha that breaks
every first R2 integration.
Presigned PUT expiry is in seconds, not milliseconds. 120 seconds is enough for a browser upload. ContentType in the command must match exactly what the browser sends. A mismatch returns SignatureDoesNotMatch; there is no fallback.
Workers binding is the zero-credential path for code running inside a Worker. Next.js server routes are not Workers. Use the S3 API there, with credentials from a secrets manager. Never hardcode credentials or use NEXT_PUBLIC_ prefixed names.
Keep buckets private by default. Use presigned GET behind a server session check for user media. Use custom domain with Cloudflare WAF for public assets. r2.dev is a development convenience, not a production delivery mechanism.
Class A operations ($4.50/million) include ListObjects. Frequent listing of large buckets is expensive. Cache list results. Run admin/crawl tooling infrequently. DeleteObject is free; clean up orphaned objects without cost pressure.
R2 is not full S3. Object ACLs, SSE-KMS, and Object Lock are not supported. For the majority of app storage (user uploads, media, exports), those gaps do not matter. For regulated workloads that need KMS-managed encryption or immutable object lock, plan accordingly.