
Cloudflare R2
- 1 installs
- 1 repo stars
- Updated October 28, 2025
- mrgoonie/xxxnaper
Implement S3-compatible object storage on Cloudflare R2 with zero egress fees, configuring buckets, uploads/downloads, CORS, and Workers integration.
About
A guide to Cloudflare R2, S3-compatible object storage with zero egress fees, covering bucket configuration, uploads, migration, and Workers integration. A developer uses it to add file storage, migrate from S3, or serve assets from R2.
- S3-compatible API usable with existing SDKs and tools
- Zero egress fees with Wrangler CLI and Workers integration
Cloudflare R2 by the numbers
- 1 all-time installs (skills.sh)
- Ranked #929 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Jul 26, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mrgoonie/xxxnaper --skill cloudflare-r2Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | October 28, 2025 |
| Repository | mrgoonie/xxxnaper ↗ |
What it does
Implement S3-compatible object storage on Cloudflare R2 with zero egress fees, configuring buckets, uploads/downloads, CORS, and Workers integration.
Files
Cloudflare R2
S3-compatible object storage with zero egress bandwidth fees. Built on Cloudflare's global network for high durability (11 nines) and strong consistency.
When to Use This Skill
- Implementing object storage for applications
- Migrating from AWS S3 or other storage providers
- Setting up file uploads/downloads
- Configuring public or private buckets
- Integrating R2 with Cloudflare Workers
- Using R2 with S3-compatible tools and SDKs
- Configuring CORS, lifecycles, or event notifications
- Optimizing storage costs with zero egress fees
Prerequisites
Required:
- Cloudflare account with R2 purchased
- Account ID from Cloudflare dashboard
For API access:
- R2 Access Keys (Access Key ID + Secret Access Key)
- Generate from: Cloudflare Dashboard → R2 → Manage R2 API Tokens
For Wrangler CLI:
npm install -g wrangler
wrangler loginCore Concepts
Architecture
- S3-compatible API - works with AWS SDKs and tools
- Workers API - native Cloudflare Workers integration
- Global network - strong consistency across all regions
- Zero egress fees - no bandwidth charges for data retrieval
Storage Classes
- Standard - default, optimized for frequent access
- Infrequent Access - lower storage cost, retrieval fees apply, 30-day minimum
Access Methods
1. R2 Workers Binding - serverless integration (recommended for new apps) 2. S3 API - compatibility with existing tools 3. Public buckets - direct HTTP access via custom domains or r2.dev 4. Presigned URLs - temporary access without credentials
Quick Start
1. Create Bucket
Wrangler:
wrangler r2 bucket create my-bucketWith location hint:
wrangler r2 bucket create my-bucket --location=wnamLocations: wnam (West NA), enam (East NA), weur (West EU), eeur (East EU), apac (Asia Pacific)
2. Upload Object
Wrangler:
wrangler r2 object put my-bucket/file.txt --file=./local-file.txtWorkers API:
await env.MY_BUCKET.put('file.txt', fileContents, {
httpMetadata: {
contentType: 'text/plain',
},
});3. Download Object
Wrangler:
wrangler r2 object get my-bucket/file.txt --file=./downloaded.txtWorkers API:
const object = await env.MY_BUCKET.get('file.txt');
const contents = await object.text();Workers Integration
Binding Configuration
wrangler.toml:
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-bucket"
preview_bucket_name = "my-bucket-preview"Common Operations
Upload with metadata:
await env.MY_BUCKET.put('user-uploads/photo.jpg', imageData, {
httpMetadata: {
contentType: 'image/jpeg',
cacheControl: 'public, max-age=31536000',
},
customMetadata: {
uploadedBy: userId,
uploadDate: new Date().toISOString(),
},
});Download with streaming:
const object = await env.MY_BUCKET.get('large-file.mp4');
if (object === null) {
return new Response('Not found', { status: 404 });
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata.contentType,
'ETag': object.etag,
},
});List objects:
const listed = await env.MY_BUCKET.list({
prefix: 'user-uploads/',
limit: 100,
});
for (const object of listed.objects) {
console.log(object.key, object.size);
}Delete object:
await env.MY_BUCKET.delete('old-file.txt');Check if object exists:
const object = await env.MY_BUCKET.head('file.txt');
if (object) {
console.log('Exists:', object.size, 'bytes');
}S3 SDK Integration
AWS CLI
Configure:
aws configure
# Access Key ID: <your-key-id>
# Secret Access Key: <your-secret>
# Region: autoOperations:
# List buckets
aws s3api list-buckets --endpoint-url https://<accountid>.r2.cloudflarestorage.com
# Upload file
aws s3 cp file.txt s3://my-bucket/ --endpoint-url https://<accountid>.r2.cloudflarestorage.com
# Generate presigned URL (expires in 1 hour)
aws s3 presign s3://my-bucket/file.txt --endpoint-url https://<accountid>.r2.cloudflarestorage.com --expires-in 3600JavaScript (AWS SDK v3)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
Body: fileContents,
}));Python (Boto3)
import boto3
s3 = boto3.client(
service_name="s3",
endpoint_url=f'https://{account_id}.r2.cloudflarestorage.com',
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
region_name="auto",
)
# Upload file
s3.upload_fileobj(file_obj, 'my-bucket', 'file.txt')
# Download file
s3.download_file('my-bucket', 'file.txt', './local-file.txt')Rclone (Large Files)
Configure:
rclone config
# Select: Amazon S3 → Cloudflare R2
# Enter credentials and endpointUpload with multipart optimization:
# For large files (>100MB)
rclone copy large-video.mp4 r2:my-bucket/ \
--s3-upload-cutoff=100M \
--s3-chunk-size=100MPublic Buckets
Enable Public Access
Wrangler:
wrangler r2 bucket create my-public-bucket
# Then enable in dashboard: R2 → Bucket → Settings → Public AccessAccess URLs
r2.dev (development only, rate-limited):
https://pub-<hash>.r2.dev/file.txtCustom domain (recommended for production): 1. Dashboard → R2 → Bucket → Settings → Public Access 2. Add custom domain 3. Cloudflare handles DNS/TLS automatically
CORS Configuration
Required for:
- Browser-based uploads
- Cross-origin API calls
- Presigned URL usage from web apps
Wrangler:
wrangler r2 bucket cors put my-bucket --rules '[
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]'Important: Origins must match exactly (no trailing slash).
Multipart Uploads
For files >100MB or parallel uploads:
Workers API:
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.mp4');
// Upload parts (5MiB - 5GiB each, max 10,000 parts)
const part1 = await multipart.uploadPart(1, chunk1);
const part2 = await multipart.uploadPart(2, chunk2);
// Complete upload
const object = await multipart.complete([part1, part2]);Constraints:
- Part size: 5MiB - 5GiB
- Max parts: 10,000
- Max object size: 5TB
- Incomplete uploads auto-abort after 7 days (configurable via lifecycle)
Data Migration
Sippy (Incremental, On-Demand)
Best for: Gradual migration, avoiding upfront egress fees
# Enable for bucket
wrangler r2 bucket sippy enable my-bucket \
--provider=aws \
--bucket=source-bucket \
--region=us-east-1 \
--access-key-id=$AWS_KEY \
--secret-access-key=$AWS_SECRETObjects migrate when first requested. Subsequent requests served from R2.
Super Slurper (Bulk, One-Time)
Best for: Complete migration, known object list
1. Dashboard → R2 → Data Migration → Super Slurper 2. Select source provider (AWS, GCS, Azure) 3. Enter credentials and bucket name 4. Start migration
Lifecycle Rules
Auto-delete or transition storage classes:
Wrangler:
wrangler r2 bucket lifecycle put my-bucket --rules '[
{
"action": {"type": "AbortIncompleteMultipartUpload"},
"filter": {},
"abortIncompleteMultipartUploadDays": 7
},
{
"action": {"type": "Transition", "storageClass": "InfrequentAccess"},
"filter": {"prefix": "archives/"},
"daysFromCreation": 90
}
]'Event Notifications
Trigger Workers on bucket events:
Wrangler:
wrangler r2 bucket notification create my-bucket \
--queue=my-queue \
--event-type=object-createSupported events:
object-create- new uploadsobject-delete- deletions
Message format:
{
"account": "account-id",
"bucket": "my-bucket",
"object": {"key": "file.txt", "size": 1024, "etag": "..."},
"action": "PutObject",
"eventTime": "2024-01-15T12:00:00Z"
}Best Practices
Performance
- Use Cloudflare Cache with custom domains for frequently accessed objects
- Multipart uploads for files >100MB (faster, more reliable)
- Rclone for batch operations (concurrent transfers)
- Location hints match user geography
Security
- Never commit Access Keys to version control
- Use environment variables for credentials
- Bucket-scoped tokens for least privilege
- Presigned URLs for temporary access
- Enable Cloudflare Access for additional protection
Cost Optimization
- Infrequent Access storage for archives (30+ day retention)
- Lifecycle rules to auto-transition or delete
- Larger multipart chunks = fewer Class A operations
- Monitor usage via dashboard analytics
Naming
- Bucket names: lowercase, hyphens, 3-63 chars
- Avoid sequential prefixes for better performance (e.g., use hashed prefixes)
- No dots in bucket names if using custom domains with TLS
Limits
- Buckets per account: 1,000
- Object size: 5TB max
- Bucket name: 3-63 characters
- Lifecycle rules: 1,000 per bucket
- Event notification rules: 100 per bucket
- r2.dev rate limit: 1,000 req/min (use custom domains for production)
Troubleshooting
401 Unauthorized:
- Verify Access Keys are correct
- Check endpoint URL includes account ID
- Ensure region is "auto" for most operations
403 Forbidden:
- Check bucket permissions and token scopes
- Verify CORS configuration for browser requests
- Confirm bucket exists and name is correct
404 Not Found:
- Object key case-sensitive
- Check bucket name spelling
- Verify object was uploaded successfully
Presigned URLs not working:
- Verify CORS configuration
- Check URL expiry time
- Ensure origin matches CORS rules exactly
Multipart upload failures:
- Part size must be 5MiB - 5GiB
- Max 10,000 parts per upload
- Complete upload within 7 days (or configure lifecycle)
Reference Files
For detailed documentation, see:
references/api-reference.md- Complete API endpoint documentationreferences/sdk-examples.md- SDK examples for all languagesreferences/workers-patterns.md- Advanced Workers integration patternsreferences/pricing-guide.md- Detailed pricing and cost optimization
Additional Resources
- Documentation: https://developers.cloudflare.com/r2/
- Wrangler Commands: https://developers.cloudflare.com/r2/reference/wrangler-commands/
- S3 Compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Workers API: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
Cloudflare R2 API Reference
Complete reference for R2 authentication, endpoints, and API operations.
Table of Contents
- Authentication & Tokens
- S3 API Endpoints
- Workers API Reference
- Presigned URLs
- S3 Extensions
- API Operations
Authentication & Tokens
Access Key Types
R2 Access Keys (S3 API):
- Access Key ID + Secret Access Key pair
- Used with S3-compatible SDKs and tools
- Generate: Dashboard → R2 → Manage R2 API Tokens
Cloudflare API Token (Management API):
- Used for Terraform and Cloudflare API
- Different from R2 Access Keys
- Generate: Dashboard → My Profile → API Tokens
Token Permissions
Account-level:
- Read-only: list all buckets, object metadata
- Read/Write: full CRUD on all buckets
- Admin: includes bucket creation/deletion
Bucket-scoped:
- Read-only: specific bucket access
- Read/Write: specific bucket CRUD
- Cannot create/delete buckets
Object-level:
- Read: specific object patterns (prefix-based)
- Write: specific object patterns
- Requires
no_check_bucket = truein some tools (Rclone)
Temporary Credentials
Generate via REST API for time-limited access:
curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/r2/temp-access-credentials" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-d '{
"ttl": 3600,
"permissions": ["read"],
"objects": ["bucket-name/*"]
}'Response includes temporary credentials valid for specified TTL.
S3 API Endpoints
Standard Endpoint
https://<account-id>.r2.cloudflarestorage.comJurisdictional Endpoints
For EU compliance:
https://<account-id>.eu.r2.cloudflarestorage.comFor FEDRAMP:
https://<account-id>.fedramp.r2.cloudflarestorage.comUsage: All S3 API calls use this endpoint. Always specify region as "auto".
S3 API Compatibility
Fully Supported Operations
Bucket Operations:
- CreateBucket (PUT /)
- ListBuckets (GET /)
- DeleteBucket (DELETE /)
- HeadBucket (HEAD /)
- GetBucketLocation
- GetBucketCors / PutBucketCors
- GetBucketEncryption / PutBucketEncryption
- GetBucketLifecycleConfiguration / PutBucketLifecycleConfiguration
Object Operations:
- PutObject (PUT /bucket/key)
- GetObject (GET /bucket/key)
- HeadObject (HEAD /bucket/key)
- DeleteObject (DELETE /bucket/key)
- CopyObject (PUT /bucket/key with x-amz-copy-source)
- ListObjectsV2 (GET /bucket?list-type=2)
Multipart Upload:
- CreateMultipartUpload
- UploadPart
- UploadPartCopy
- CompleteMultipartUpload
- AbortMultipartUpload
- ListMultipartUploads
- ListParts
Checksum Support
R2 supports these checksum algorithms:
- CRC32 - fastest, good for error detection
- CRC32C - faster than MD5, better error detection
- SHA-1 - cryptographic hash
- SHA-256 - strong cryptographic hash
Usage:
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
Body: contents,
ChecksumAlgorithm: "SHA256",
ChecksumSHA256: computedChecksum,
}));Storage Classes
STANDARD- default storage classINFREQUENT_ACCESS- lower storage cost, retrieval fees
Set during upload:
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "archive.zip",
Body: contents,
StorageClass: "INFREQUENT_ACCESS",
}));SSE-C (Server-Side Encryption with Customer Keys)
Encrypt objects with your own keys:
const encryptionKey = crypto.randomBytes(32);
const keyMD5 = crypto.createHash('md5').update(encryptionKey).digest('base64');
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "sensitive.txt",
Body: contents,
SSECustomerAlgorithm: "AES256",
SSECustomerKey: encryptionKey.toString('base64'),
SSECustomerKeyMD5: keyMD5,
}));Important: Must provide same key for GET/HEAD operations. Key not stored by R2.
Differences from AWS S3
Not Supported:
- Bucket versioning
- Object locking (WORM)
- S3 Select
- Bucket policies (use Cloudflare Access instead)
- ACLs (use bucket-scoped tokens)
- Website hosting (use Workers Sites or Pages)
- Requester Pays
- Inventory reports
- S3 Transfer Acceleration
Different Behavior:
- ListObjectsV2: max 1,000 keys per request (vs 1,000 in AWS)
- ETag format differs for multipart uploads
- No bucket ownership controls (single account per bucket)
S3 Extensions
Cloudflare-specific enhancements to S3 API.
1. Unicode Metadata (RFC 2047)
Store UTF-8 metadata values:
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
Body: contents,
Metadata: {
'author': '=?UTF-8?B?' + Buffer.from('Renée').toString('base64') + '?=',
},
}));Decode on retrieval:
const decoded = metadata.author.replace(
/=\?UTF-8\?B\?(.+?)\?=/g,
(_, encoded) => Buffer.from(encoded, 'base64').toString('utf8')
);2. Auto-Bucket Creation
R2 creates non-existent buckets automatically during PutObject (if token has permission).
Opt-out via header:
cf-create-bucket-if-missing: false3. Conditional PutObject
Prevent overwrites with condition:
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
Body: contents,
Metadata: {
'cf-copy-if-none-match': '*', // Fail if object exists
},
}));Returns 412 Precondition Failed if object exists.
4. MERGE Metadata Directive
Merge metadata instead of replacing:
await s3.send(new CopyObjectCommand({
Bucket: "my-bucket",
CopySource: "/my-bucket/file.txt",
Key: "file.txt",
Metadata: {
'new-field': 'value',
},
MetadataDirective: "MERGE", // Cloudflare extension
}));Keeps existing metadata, adds/updates specified fields.
5. Enhanced ListBuckets
Pagination support via start-after:
const response = await s3.send(new ListBucketsCommand({}));
// Response includes ContinuationToken if more results
const nextPage = await s3.send(new ListBucketsCommand({
ContinuationToken: response.ContinuationToken,
}));6. Conditional CopyObject (Beta)
Prevent destination overwrites:
await s3.send(new CopyObjectCommand({
Bucket: "my-bucket",
CopySource: "/source-bucket/file.txt",
Key: "destination.txt",
Metadata: {
'cf-copy-destination-if-none-match': '*',
},
}));Presigned URLs
Temporary URLs for client-side access without credentials.
Generation (JavaScript)
Read URL:
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const url = await getSignedUrl(
s3Client,
new GetObjectCommand({ Bucket: "my-bucket", Key: "file.txt" }),
{ expiresIn: 3600 } // 1 hour
);Write URL:
import { PutObjectCommand } from "@aws-sdk/client-s3";
const uploadUrl = await getSignedUrl(
s3Client,
new PutObjectCommand({ Bucket: "my-bucket", Key: "upload.txt" }),
{ expiresIn: 3600 }
);
// Client uploads via PUT request
fetch(uploadUrl, { method: 'PUT', body: fileData });Expiry Range
- Minimum: 1 second
- Maximum: 7 days (604,800 seconds)
Limitations
- Cannot use with custom domains (requires WAF Pro+ plan)
- Use r2.dev endpoint or direct S3 endpoint
- CORS configuration required for browser usage
Security Considerations
- URLs contain credentials in query parameters
- Treat as sensitive (don't log or embed publicly)
- Shorter expiry = better security
- Consider Workers API for production apps (no URL exposure)
Workers API Reference
Native R2 integration for Cloudflare Workers.
Binding Setup
wrangler.toml:
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "production-bucket"
preview_bucket_name = "preview-bucket"
jurisdiction = "eu" # Optional: 'eu' or 'fedramp'TypeScript types:
interface Env {
MY_BUCKET: R2Bucket;
}R2Bucket Methods
head(key: string): Promise<R2Object | null>
Check if object exists, get metadata:
const object = await env.MY_BUCKET.head('file.txt');
if (object) {
console.log(object.size, object.etag, object.uploaded);
}get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>
Retrieve object:
const object = await env.MY_BUCKET.get('file.txt');
if (object === null) {
return new Response('Not found', { status: 404 });
}
const text = await object.text();
// Also: object.arrayBuffer(), object.blob(), object.json()Options:
{
range?: { offset: number, length?: number } | { suffix: number },
onlyIf?: {
etagMatches?: string,
etagDoesNotMatch?: string,
uploadedBefore?: Date,
uploadedAfter?: Date,
}
}put(key: string, value: ReadableStream | ArrayBuffer | string | Blob, options?: R2PutOptions): Promise<R2Object>
Upload object:
await env.MY_BUCKET.put('file.txt', 'Hello, World!', {
httpMetadata: {
contentType: 'text/plain',
contentLanguage: 'en-US',
contentDisposition: 'inline',
contentEncoding: 'identity',
cacheControl: 'public, max-age=3600',
cacheExpiry: new Date(Date.now() + 86400000),
},
customMetadata: {
userId: '12345',
purpose: 'example',
},
storageClass: 'Standard', // or 'InfrequentAccess'
md5: computedMD5, // Optional validation
sha1: computedSHA1,
sha256: computedSHA256,
sha384: computedSHA384,
sha512: computedSHA512,
});delete(keys: string | string[]): Promise<void>
Delete object(s):
await env.MY_BUCKET.delete('file.txt');
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']);list(options?: R2ListOptions): Promise<R2Objects>
List objects:
const listed = await env.MY_BUCKET.list({
prefix: 'uploads/',
delimiter: '/',
cursor: '',
limit: 1000,
include: ['httpMetadata', 'customMetadata'],
});
for (const obj of listed.objects) {
console.log(obj.key, obj.size);
}
if (listed.truncated) {
const next = await env.MY_BUCKET.list({ cursor: listed.cursor });
}Multipart Upload API
For large files (>100MB) or parallel uploads:
createMultipartUpload(key: string, options?: R2PutOptions): Promise<R2MultipartUpload>
const upload = await env.MY_BUCKET.createMultipartUpload('large.mp4', {
httpMetadata: { contentType: 'video/mp4' },
});
console.log(upload.uploadId, upload.key);uploadPart(partNumber: number, value: ReadableStream | ArrayBuffer | string): Promise<R2UploadedPart>
const part1 = await upload.uploadPart(1, chunk1);
const part2 = await upload.uploadPart(2, chunk2);
console.log(part1.partNumber, part1.etag);Constraints:
- Part numbers: 1-10,000
- Part size: 5 MiB - 5 GiB
- Last part can be smaller than 5 MiB
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>
const object = await upload.complete([part1, part2, part3]);
console.log('Upload complete:', object.key);abort(): Promise<void>
await upload.abort(); // Cancel incomplete uploadR2Object Properties
{
key: string; // Object key
version: string; // Version ID
size: number; // Bytes
etag: string; // Entity tag
httpEtag: string; // Quoted ETag for HTTP
uploaded: Date; // Upload timestamp
checksums: {
md5?: ArrayBuffer;
sha1?: ArrayBuffer;
sha256?: ArrayBuffer;
sha384?: ArrayBuffer;
sha512?: ArrayBuffer;
};
httpMetadata: {
contentType?: string;
contentLanguage?: string;
contentDisposition?: string;
contentEncoding?: string;
cacheControl?: string;
cacheExpiry?: Date;
};
customMetadata: Record<string, string>;
range?: { offset: number; length: number };
storageClass: 'Standard' | 'InfrequentAccess';
}R2ObjectBody (extends R2Object)
Additional methods for reading data:
body: ReadableStream;
bodyUsed: boolean;
text(): Promise<string>;
json<T>(): Promise<T>;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;Memory Limits
Workers have memory constraints:
- 128MB per request - don't buffer large files
- Use streaming for files >10MB
- Multipart uploads bypass memory limits (parts uploaded separately)
Streaming example:
const object = await env.MY_BUCKET.get('large.mp4');
return new Response(object.body); // Stream, don't bufferAPI Operations Reference
Bucket Operations
| Operation | Method | Endpoint | Description |
|---|---|---|---|
| List Buckets | GET | / | List all buckets in account |
| Create Bucket | PUT | /{bucket} | Create new bucket |
| Delete Bucket | DELETE | /{bucket} | Delete empty bucket |
| Head Bucket | HEAD | /{bucket} | Check bucket exists |
| Get Location | GET | /{bucket}?location | Get bucket location |
| Get CORS | GET | /{bucket}?cors | Get CORS config |
| Put CORS | PUT | /{bucket}?cors | Set CORS config |
| Delete CORS | DELETE | /{bucket}?cors | Remove CORS config |
| Get Lifecycle | GET | /{bucket}?lifecycle | Get lifecycle rules |
| Put Lifecycle | PUT | /{bucket}?lifecycle | Set lifecycle rules |
Object Operations
| Operation | Method | Endpoint | Description |
|---|---|---|---|
| List Objects | GET | /{bucket}?list-type=2 | List objects (v2) |
| Get Object | GET | /{bucket}/{key} | Download object |
| Head Object | HEAD | /{bucket}/{key} | Get object metadata |
| Put Object | PUT | /{bucket}/{key} | Upload object |
| Delete Object | DELETE | /{bucket}/{key} | Delete object |
| Copy Object | PUT | /{bucket}/{key} + header | Copy object |
Multipart Upload Operations
| Operation | Method | Endpoint | Description |
|---|---|---|---|
| Create | POST | /{bucket}/{key}?uploads | Start multipart upload |
| Upload Part | PUT | /{bucket}/{key}?partNumber=N&uploadId=ID | Upload part |
| List Parts | GET | /{bucket}/{key}?uploadId=ID | List uploaded parts |
| Complete | POST | /{bucket}/{key}?uploadId=ID | Finish upload |
| Abort | DELETE | /{bucket}/{key}?uploadId=ID | Cancel upload |
| List Uploads | GET | /{bucket}?uploads | List incomplete uploads |
Class A Operations (Write/List)
Higher cost ($4.50/million after free tier):
- ListBuckets, PutBucket, ListObjects
- PutObject, CopyObject
- CreateMultipartUpload, UploadPart, CompleteMultipartUpload
- LifecycleStorageTierTransition
- PutBucketCors, PutBucketLifecycleConfiguration
Class B Operations (Read)
Lower cost ($0.36/million after free tier):
- HeadBucket, HeadObject, GetObject
- GetBucketLocation, GetBucketCors, GetBucketLifecycleConfiguration
Free Operations
- DeleteObject, DeleteBucket
- AbortMultipartUpload
Rate Limiting
r2.dev domain:
- 1,000 requests per minute per bucket
- Use custom domains for production (no rate limits)
S3 API:
- No documented rate limits on standard endpoint
- Use reasonable request rates (<10,000 req/sec)
Workers API:
- Subject to Workers request limits
- Subrequests count toward subrequest limit (50 per request)
Error Codes
Common HTTP Status Codes
- 200 OK - Success
- 204 No Content - Success (delete operations)
- 400 Bad Request - Invalid request format
- 401 Unauthorized - Invalid credentials
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Bucket or object doesn't exist
- 409 Conflict - Bucket name already taken
- 412 Precondition Failed - Conditional request failed
- 413 Payload Too Large - Object exceeds size limit
- 500 Internal Server Error - R2 service error
S3 Error Codes
<Error>
<Code>NoSuchKey</Code>
<Message>The specified key does not exist</Message>
<Key>nonexistent.txt</Key>
<RequestId>...</RequestId>
</Error>Common codes:
NoSuchBucket- Bucket doesn't existNoSuchKey- Object doesn't existAccessDenied- Insufficient permissionsInvalidAccessKeyId- Bad Access Key IDSignatureDoesNotMatch- Bad Secret Access KeyBucketAlreadyExists- Bucket name takenEntityTooLarge- Object > 5TB
Best Practices
Authentication
- Store credentials in environment variables
- Use bucket-scoped tokens when possible
- Rotate tokens periodically
- Never commit credentials to version control
API Usage
- Use Workers API for new projects (better performance, no exposed credentials)
- Batch operations when possible (e.g., list + process)
- Implement retry logic with exponential backoff
- Handle 429 rate limit responses gracefully
Error Handling
- Check HTTP status codes
- Parse S3 error responses for details
- Log errors with request IDs for support
- Implement fallback strategies
Performance
- Use multipart uploads for files >100MB
- Stream large responses (don't buffer)
- Enable Cloudflare Cache for read-heavy workloads
- Use location hints matching user geography
Security
- Use HTTPS for all API calls
- Validate checksums on critical uploads
- Implement CORS carefully (don't use wildcard in production)
- Audit access patterns via R2 analytics
Cloudflare R2 Pricing Guide
Comprehensive guide to R2 pricing, cost optimization, and billing.
Table of Contents
- Pricing Overview
- Storage Classes
- Operations Pricing
- Cost Calculations
- Cost Optimization Strategies
- Billing Examples
- Migration Cost Analysis
- Comparison with Competitors
---
Pricing Overview
R2's key differentiator: zero egress fees. No charges for bandwidth when retrieving data.
Standard Storage
| Component | Free Tier | Paid Rate |
|---|---|---|
| Storage | 10 GB-month/month | $0.015/GB-month |
| Class A Operations (write/list) | 1 million/month | $4.50/million |
| Class B Operations (read) | 10 million/month | $0.36/million |
| Egress Bandwidth | Unlimited FREE | FREE |
Infrequent Access Storage
| Component | Free Tier | Paid Rate |
|---|---|---|
| Storage | None | $0.01/GB-month |
| Class A Operations | None | $9.00/million |
| Class B Operations | None | $0.90/million |
| Data Retrieval | None | $0.01/GB |
| Minimum Storage Duration | N/A | 30 days |
| Egress Bandwidth | Unlimited FREE | FREE |
---
Storage Classes
Standard Storage
Best for:
- Frequently accessed data
- Active applications
- Dynamic content
- User uploads
- Content delivery
Characteristics:
- No retrieval fees
- Lower operation costs
- No minimum storage duration
- Optimized for frequent access
Infrequent Access (IA)
Best for:
- Backups and archives
- Cold data (accessed <1x/month)
- Compliance data retention
- Log archives
- Historical data
Characteristics:
- 33% cheaper storage ($0.01 vs $0.015)
- Higher operation costs (2x Class B, 2.5x Class A)
- $0.01/GB retrieval fee
- 30-day minimum billing (billed for 30 days even if deleted earlier)
Break-even analysis:
Standard: $0.015/GB-month storage + $0.36/million reads
IA: $0.01/GB-month storage + $0.90/million reads + $0.01/GB retrieval
IA saves money when:
Retrieval cost < Storage savingsExample: 1TB data, accessed 10x/year
- Standard: $15/month + negligible operations = $180/year
- IA: $10/month + $100/year retrieval = $220/year
- Verdict: Standard better for 10x/year access
Example: 1TB data, accessed 2x/year
- Standard: $180/year
- IA: $120/year + $20 retrieval = $140/year
- Verdict: IA better for 2x/year access
---
Operations Pricing
Class A Operations ($4.50/million after free tier)
Write Operations:
- PutObject
- CopyObject
- CompleteMultipartUpload
- CreateMultipartUpload
- UploadPart
- UploadPartCopy
List Operations:
- ListBuckets
- ListObjects
- ListObjectsV2
- ListMultipartUploads
- ListParts
Management Operations:
- PutBucketCors
- PutBucketLifecycleConfiguration
- PutBucketEncryption
Lifecycle Transitions:
- Storage class transitions (Standard ↔ IA)
Class B Operations ($0.36/million after free tier)
Read Operations:
- GetObject
- HeadObject
- HeadBucket
Metadata Operations:
- GetBucketLocation
- GetBucketCors
- GetBucketLifecycleConfiguration
- GetBucketEncryption
Free Operations
- DeleteObject
- DeleteBucket
- AbortMultipartUpload
---
Cost Calculations
Storage Billing Metric
GB-month: Average peak storage per day over billing period.
Calculation:
Daily peak storage (30 days)
Sum of daily peaks / 30 days = Average GB-month
Rounded up to next billing unitExample:
Day 1-10: 50 GB
Day 11-20: 100 GB
Day 21-30: 75 GB
Average = (10×50 + 10×100 + 10×75) / 30 = 75 GB-monthRounding: Usage rounded up (1.1 GB-month billed as 2 GB-month)
Multipart Upload Costs
Standard approach (5MB parts):
1GB file = 200 parts
Operations = 1 (create) + 200 (upload parts) + 1 (complete) = 202 Class A ops
Cost = 202 / 1,000,000 × $4.50 = $0.00091Optimized approach (100MB parts):
1GB file = 10 parts
Operations = 1 + 10 + 1 = 12 Class A ops
Cost = 12 / 1,000,000 × $4.50 = $0.000054Savings: 94% fewer operations for large chunks
Lifecycle Transition Costs
Scenario: Transition 1TB to Infrequent Access after 90 days
Transition cost:
- 1TB = 1,048,576 objects (1MB each)
- Transitions = 1,048,576 Class A operations
- Cost = 1.05 million / 1 million × $4.50 = $4.73
Monthly savings:
- Standard: 1,000 GB × $0.015 = $15
- IA: 1,000 GB × $0.01 = $10
- Savings: $5/month
Payback period: 1 month (transition cost recovered)
---
Cost Optimization Strategies
1. Maximize Free Tier Usage
Free tier includes:
- 10 GB storage
- 1 million Class A operations/month
- 10 million Class B operations/month
Strategy: Use for development, testing, small projects
2. Optimize Multipart Upload Chunk Size
Recommendation: Use largest practical chunk size
# Rclone optimization
rclone copy large-files/ r2:bucket/ \
--s3-chunk-size=100M \
--s3-upload-cutoff=100MSavings: Up to 95% fewer Class A operations
3. Batch Operations
Bad practice:
// 1,000 separate list operations
for (const prefix of prefixes) {
await env.BUCKET.list({ prefix, limit: 1 });
}Good practice:
// 1 list operation with post-processing
const all = await env.BUCKET.list({ limit: 1000 });
const filtered = all.objects.filter(obj => prefixes.some(p => obj.key.startsWith(p)));4. Lifecycle Rules for Automatic Transitions
Example: Move logs to IA after 30 days
wrangler r2 bucket lifecycle put my-bucket --rules '[
{
"action": {"type": "Transition", "storageClass": "InfrequentAccess"},
"filter": {"prefix": "logs/"},
"daysFromCreation": 30
}
]'Savings: Automatic 33% storage cost reduction for old data
5. Abort Incomplete Multipart Uploads
Cost impact: Incomplete uploads still charged for storage
wrangler r2 bucket lifecycle put my-bucket --rules '[
{
"action": {"type": "AbortIncompleteMultipartUpload"},
"filter": {},
"abortIncompleteMultipartUploadDays": 7
}
]'6. Delete Unnecessary Objects
Deletions are free - regularly clean up unused data
7. Use Custom Domains with Cloudflare Cache
Benefit: Reduce Class B operations by caching at edge
Setup: 1. Add custom domain to bucket 2. Enable Cloudflare Cache 3. Set appropriate Cache-Control headers
Example:
await env.BUCKET.put(key, data, {
httpMetadata: {
cacheControl: 'public, max-age=31536000', // 1 year
},
});Savings: 95%+ reduction in Class B operations for popular files
8. Optimize Object Listing
Bad practice:
// List all 1M objects
const all = await env.BUCKET.list({ limit: 1000 });
// Requires 1,000 API callsGood practice:
// Use prefix to narrow search
const specific = await env.BUCKET.list({
prefix: 'user-123/',
limit: 100,
});
// Single API call9. Choose Right Storage Class at Upload
Avoid transition costs:
// For known archive data, use IA immediately
await env.BUCKET.put(key, data, {
storageClass: 'InfrequentAccess',
});Savings: Skip transition Class A operation
---
Billing Examples
Example 1: Small SaaS Application
Usage:
- 50 GB storage
- 5,000 uploads/month (PutObject)
- 500,000 downloads/month (GetObject)
- 10,000 list operations
Cost calculation:
Storage: (50 - 10) GB × $0.015 = $0.60
Class A: (5,000 + 10,000 - 1,000,000) = 0 (within free tier)
Class B: (500,000 - 10,000,000) = 0 (within free tier)
Egress: FREE
Total: $0.60/monthExample 2: Content Delivery Platform
Usage:
- 5 TB storage
- 100,000 uploads/month
- 50 million downloads/month
- No list operations (direct access)
Cost calculation:
Storage: 5,000 GB × $0.015 = $75.00
Class A: (100,000 - 1,000,000) = 0 (within free tier)
Class B: (50,000,000 - 10,000,000) = 40M × $0.36/M = $14.40
Egress: FREE
Total: $89.40/monthAWS S3 comparison:
Storage: 5,000 GB × $0.023 = $115.00
Data transfer out: 50M downloads × 1MB avg = 50TB
Egress: 50,000 GB × $0.09 = $4,500.00
Total: $4,615.00/monthSavings with R2: $4,525.60/month (98% reduction)
Example 3: Backup & Archive Service
Usage:
- 100 TB storage (Infrequent Access)
- 10,000 uploads/month
- 1,000 restores/month (100 GB total retrieval)
Cost calculation:
Storage: 100,000 GB × $0.01 = $1,000.00
Class A: 10,000 × $9.00/M = $0.09
Class B: 1,000 × $0.90/M = $0.00 (negligible)
Retrieval: 100 GB × $0.01 = $1.00
Egress: FREE
Total: $1,001.09/monthStandard storage alternative:
Storage: 100,000 GB × $0.015 = $1,500.00
Operations: negligible
Total: $1,500.00/monthSavings with IA: $498.91/month (33% reduction)
---
Migration Cost Analysis
Scenario: Migrating 10TB from AWS S3
Super Slurper (bulk migration):
Objects: 10,000 (1GB each)
Class A operations: 10,000 (multipart uploads)
Cost: 10,000 / 1,000,000 × $4.50 = $0.045
AWS egress: 10,000 GB × $0.09 = $900.00
Total migration cost: $900.045 (AWS egress dominates)Sippy (incremental migration):
Objects migrate on first request
No upfront egress fees
Pay AWS egress as objects accessed
Month 1: 20% accessed = $180 AWS egress
Month 2: 30% accessed = $270 AWS egress
Month 3: 50% accessed = $450 AWS egress
Total: $900 (same, but spread over time)Cost-benefit timeline:
Before migration (AWS S3):
- Storage: $230/month
- Egress (avg 1TB/month): $90/month
- Total: $320/month
After migration (R2):
- Storage: $150/month
- Egress: $0/month
- Total: $150/month
Savings: $170/month Migration ROI: 5.3 months to recover egress costs
---
Comparison with Competitors
AWS S3 Standard
| Feature | R2 | S3 |
|---|---|---|
| Storage | $0.015/GB | $0.023/GB |
| PUT requests | $4.50/M | $5.00/M |
| GET requests | $0.36/M | $0.40/M |
| Egress | FREE | $0.09/GB |
R2 savings: 100% on egress, 35% on storage
Google Cloud Storage
| Feature | R2 | GCS |
|---|---|---|
| Storage | $0.015/GB | $0.020/GB |
| Class A ops | $4.50/M | $5.00/M |
| Class B ops | $0.36/M | $0.40/M |
| Egress | FREE | $0.12/GB |
R2 savings: 100% on egress, 25% on storage
Azure Blob Storage
| Feature | R2 | Azure |
|---|---|---|
| Storage | $0.015/GB | $0.018/GB |
| Write ops | $4.50/M | $5.00/M |
| Read ops | $0.36/M | $0.40/M |
| Egress | FREE | $0.087/GB |
R2 savings: 100% on egress, 17% on storage
---
Cost Monitoring
Dashboard Analytics
Access via: Dashboard → R2 → Bucket → Metrics
Available metrics:
- Storage usage (GB-month)
- Class A operation count
- Class B operation count
- Request rates
GraphQL API Queries
query {
viewer {
accounts(filter: { accountTag: $accountId }) {
r2OperationsAdaptiveGroups(
limit: 100
filter: {
datetime_geq: "2024-01-01T00:00:00Z"
datetime_lt: "2024-02-01T00:00:00Z"
}
) {
sum {
requests
}
dimensions {
date
actionType
}
}
}
}
}Cost Alerts
Set up billing alerts: 1. Dashboard → Billing → Notifications 2. Configure threshold (e.g., $100/month) 3. Receive email when exceeded
---
Best Practices Summary
1. Use free tier for dev/test environments 2. Optimize multipart uploads with large chunks 3. Enable lifecycle rules for automatic transitions 4. Cache frequently accessed files at Cloudflare edge 5. Delete incomplete multipart uploads after 7 days 6. Use Infrequent Access for data accessed <1x/month 7. Batch operations to reduce API calls 8. Monitor costs via dashboard analytics 9. Choose storage class at upload to avoid transition costs 10. Leverage zero egress for high-bandwidth applications
---
Key Takeaway
R2's zero egress fees make it dramatically cheaper for:
- Content delivery networks
- Data-heavy applications
- Video/media streaming
- Backup/restore services
- Multi-cloud architectures
Cost savings increase with bandwidth usage - the more data transferred, the greater the savings over traditional cloud storage.
Cloudflare R2 SDK Examples
Comprehensive examples for all supported SDKs and tools.
Table of Contents
- AWS CLI
- AWS SDK JavaScript v3
- AWS SDK Python (Boto3)
- AWS SDK Go v2
- AWS SDK Java
- AWS SDK .NET
- AWS SDK PHP
- AWS SDK Ruby
- AWS SDK Rust
- Rclone
- Terraform
Prerequisites
All SDKs require:
- Access Key ID and Secret Access Key from Cloudflare dashboard
- Account ID from Cloudflare dashboard
- Endpoint:
https://<account-id>.r2.cloudflarestorage.com - Region:
auto(or specific:wnam,enam,weur,eeur,apac)
---
AWS CLI
Installation: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
Configuration
aws configure
# AWS Access Key ID: <your-access-key-id>
# AWS Secret Access Key: <your-secret-access-key>
# Default region name: auto
# Default output format: jsonCommon Operations
List buckets:
aws s3api list-buckets \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comCreate bucket:
aws s3api create-bucket \
--bucket my-bucket \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comUpload file:
aws s3 cp file.txt s3://my-bucket/ \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comUpload with metadata:
aws s3 cp file.txt s3://my-bucket/ \
--metadata '{"author":"John","version":"1.0"}' \
--content-type "text/plain" \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comDownload file:
aws s3 cp s3://my-bucket/file.txt ./downloaded.txt \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comList objects:
aws s3api list-objects-v2 \
--bucket my-bucket \
--prefix uploads/ \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comDelete object:
aws s3 rm s3://my-bucket/file.txt \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comGenerate presigned URL (read):
aws s3 presign s3://my-bucket/file.txt \
--expires-in 3600 \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comSync directory:
aws s3 sync ./local-dir s3://my-bucket/remote-dir/ \
--endpoint-url https://<accountid>.r2.cloudflarestorage.comMultipart upload (automatic for large files):
aws s3 cp large-file.mp4 s3://my-bucket/ \
--endpoint-url https://<accountid>.r2.cloudflarestorage.com
# AWS CLI automatically uses multipart for files >8MBShell Alias (Convenience)
# Add to ~/.bashrc or ~/.zshrc
alias r2='aws s3 --endpoint-url https://<accountid>.r2.cloudflarestorage.com'
# Usage
r2 ls s3://my-bucket/
r2 cp file.txt s3://my-bucket/---
AWS SDK JavaScript v3 (Node.js)
Installation:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presignerConfiguration
import { S3Client } from "@aws-sdk/client-s3";
const ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
const ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
const SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
const s3 = new S3Client({
region: "auto",
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: ACCESS_KEY_ID,
secretAccessKey: SECRET_ACCESS_KEY,
},
});Common Operations
List buckets:
import { ListBucketsCommand } from "@aws-sdk/client-s3";
const { Buckets } = await s3.send(new ListBucketsCommand({}));
console.log(Buckets);Create bucket:
import { CreateBucketCommand } from "@aws-sdk/client-s3";
await s3.send(new CreateBucketCommand({
Bucket: "my-bucket",
}));Upload object:
import { PutObjectCommand } from "@aws-sdk/client-s3";
import fs from "fs";
const fileContent = fs.readFileSync("./file.txt");
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "uploads/file.txt",
Body: fileContent,
ContentType: "text/plain",
Metadata: {
author: "John Doe",
version: "1.0",
},
}));Upload with custom metadata:
await s3.send(new PutObjectCommand({
Bucket: "my-bucket",
Key: "photo.jpg",
Body: imageBuffer,
ContentType: "image/jpeg",
CacheControl: "public, max-age=31536000",
Metadata: {
uploadedBy: userId,
uploadDate: new Date().toISOString(),
},
}));Download object:
import { GetObjectCommand } from "@aws-sdk/client-s3";
const { Body, ContentType } = await s3.send(new GetObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
}));
// Convert stream to string
const text = await Body.transformToString();
console.log(text);Stream download to file:
import { pipeline } from "stream/promises";
import { createWriteStream } from "fs";
const { Body } = await s3.send(new GetObjectCommand({
Bucket: "my-bucket",
Key: "large-file.mp4",
}));
await pipeline(Body, createWriteStream("./downloaded.mp4"));List objects:
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
const { Contents } = await s3.send(new ListObjectsV2Command({
Bucket: "my-bucket",
Prefix: "uploads/",
MaxKeys: 100,
}));
for (const object of Contents) {
console.log(object.Key, object.Size);
}Paginated listing:
let ContinuationToken;
do {
const response = await s3.send(new ListObjectsV2Command({
Bucket: "my-bucket",
ContinuationToken,
}));
for (const obj of response.Contents) {
console.log(obj.Key);
}
ContinuationToken = response.NextContinuationToken;
} while (ContinuationToken);Delete object:
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
await s3.send(new DeleteObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
}));Delete multiple objects:
import { DeleteObjectsCommand } from "@aws-sdk/client-s3";
await s3.send(new DeleteObjectsCommand({
Bucket: "my-bucket",
Delete: {
Objects: [
{ Key: "file1.txt" },
{ Key: "file2.txt" },
{ Key: "file3.txt" },
],
},
}));Generate presigned URL (read):
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: "my-bucket",
Key: "file.txt",
}),
{ expiresIn: 3600 } // 1 hour
);
console.log("Download URL:", url);Generate presigned URL (write):
import { PutObjectCommand } from "@aws-sdk/client-s3";
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: "my-bucket",
Key: "user-upload.jpg",
ContentType: "image/jpeg",
}),
{ expiresIn: 1800 } // 30 minutes
);
// Client uploads via PUT
// fetch(uploadUrl, { method: 'PUT', body: fileData });Multipart upload:
import {
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
} from "@aws-sdk/client-s3";
// 1. Create multipart upload
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: "my-bucket",
Key: "large-file.mp4",
ContentType: "video/mp4",
}));
// 2. Upload parts (5MB - 5GB each)
const part1 = await s3.send(new UploadPartCommand({
Bucket: "my-bucket",
Key: "large-file.mp4",
UploadId,
PartNumber: 1,
Body: chunk1,
}));
const part2 = await s3.send(new UploadPartCommand({
Bucket: "my-bucket",
Key: "large-file.mp4",
UploadId,
PartNumber: 2,
Body: chunk2,
}));
// 3. Complete upload
await s3.send(new CompleteMultipartUploadCommand({
Bucket: "my-bucket",
Key: "large-file.mp4",
UploadId,
MultipartUpload: {
Parts: [
{ PartNumber: 1, ETag: part1.ETag },
{ PartNumber: 2, ETag: part2.ETag },
],
},
}));---
AWS SDK Python (Boto3)
Installation:
pip install boto3Configuration
Method 1: Explicit credentials
import boto3
ACCOUNT_ID = "your-account-id"
ACCESS_KEY_ID = "your-access-key-id"
SECRET_ACCESS_KEY = "your-secret-access-key"
s3 = boto3.client(
service_name="s3",
endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
aws_access_key_id=ACCESS_KEY_ID,
aws_secret_access_key=SECRET_ACCESS_KEY,
region_name="auto",
)Method 2: Environment variables
import boto3
import os
# Set: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
s3 = boto3.client(
"s3",
endpoint_url=f"https://{os.getenv('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com",
)Method 3: Resource interface
s3_resource = boto3.resource(
"s3",
endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
aws_access_key_id=ACCESS_KEY_ID,
aws_secret_access_key=SECRET_ACCESS_KEY,
)Common Operations
List buckets:
response = s3.list_buckets()
for bucket in response['Buckets']:
print(bucket['Name'])Create bucket:
s3.create_bucket(Bucket="my-bucket")Upload file:
s3.upload_file("local-file.txt", "my-bucket", "remote-file.txt")Upload from bytes:
import io
file_content = b"Hello, World!"
s3.upload_fileobj(
io.BytesIO(file_content),
"my-bucket",
"file.txt",
ExtraArgs={"ContentType": "text/plain"}
)Upload with metadata:
s3.put_object(
Bucket="my-bucket",
Key="photo.jpg",
Body=image_data,
ContentType="image/jpeg",
Metadata={
"author": "John Doe",
"upload-date": "2024-01-15",
},
CacheControl="public, max-age=31536000",
)Download file:
s3.download_file("my-bucket", "file.txt", "./local-file.txt")Download to memory:
import io
buffer = io.BytesIO()
s3.download_fileobj("my-bucket", "file.txt", buffer)
content = buffer.getvalue()Get object info:
response = s3.head_object(Bucket="my-bucket", Key="file.txt")
print(f"Size: {response['ContentLength']} bytes")
print(f"Type: {response['ContentType']}")
print(f"Metadata: {response.get('Metadata', {})}")List objects:
response = s3.list_objects_v2(Bucket="my-bucket", Prefix="uploads/")
for obj in response.get("Contents", []):
print(f"{obj['Key']}: {obj['Size']} bytes")Paginated listing:
paginator = s3.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket="my-bucket", Prefix="logs/")
for page in pages:
for obj in page.get("Contents", []):
print(obj["Key"])Delete object:
s3.delete_object(Bucket="my-bucket", Key="file.txt")Delete multiple objects:
s3.delete_objects(
Bucket="my-bucket",
Delete={
"Objects": [
{"Key": "file1.txt"},
{"Key": "file2.txt"},
{"Key": "file3.txt"},
]
}
)Generate presigned URL:
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-bucket", "Key": "file.txt"},
ExpiresIn=3600, # 1 hour
)
print(f"Download URL: {url}")Multipart upload (automatic):
# Boto3 automatically uses multipart for large files
s3.upload_file(
"large-video.mp4",
"my-bucket",
"videos/video.mp4",
Config=boto3.s3.transfer.TransferConfig(
multipart_threshold=100 * 1024 * 1024, # 100MB
multipart_chunksize=100 * 1024 * 1024, # 100MB chunks
)
)Manual multipart upload:
# 1. Create multipart upload
mpu = s3.create_multipart_upload(
Bucket="my-bucket",
Key="large-file.mp4",
ContentType="video/mp4",
)
upload_id = mpu["UploadId"]
# 2. Upload parts
parts = []
for i, chunk in enumerate(file_chunks, start=1):
part = s3.upload_part(
Bucket="my-bucket",
Key="large-file.mp4",
UploadId=upload_id,
PartNumber=i,
Body=chunk,
)
parts.append({"PartNumber": i, "ETag": part["ETag"]})
# 3. Complete upload
s3.complete_multipart_upload(
Bucket="my-bucket",
Key="large-file.mp4",
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)---
AWS SDK Go v2
Installation:
go get github.com/aws/aws-sdk-go-v2/aws
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/credentials
go get github.com/aws/aws-sdk-go-v2/service/s3Configuration
package main
import (
"context"
"fmt"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func createClient() (*s3.Client, error) {
accountID := os.Getenv("R2_ACCOUNT_ID")
accessKeyID := os.Getenv("R2_ACCESS_KEY_ID")
secretAccessKey := os.Getenv("R2_SECRET_ACCESS_KEY")
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
accessKeyID,
secretAccessKey,
"",
),
),
config.WithRegion("auto"),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(
fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID),
)
})
return client, nil
}Common Operations
List buckets:
output, err := client.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
if err != nil {
log.Fatal(err)
}
for _, bucket := range output.Buckets {
fmt.Println(*bucket.Name)
}Upload object:
file, err := os.Open("file.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
_, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("uploads/file.txt"),
Body: file,
ContentType: aws.String("text/plain"),
})Download object:
output, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("file.txt"),
})
if err != nil {
log.Fatal(err)
}
defer output.Body.Close()
data, err := io.ReadAll(output.Body)
fmt.Println(string(data))List objects:
output, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: aws.String("my-bucket"),
Prefix: aws.String("uploads/"),
})
for _, object := range output.Contents {
fmt.Printf("%s: %d bytes\n", *object.Key, object.Size)
}Generate presigned URL:
import "github.com/aws/aws-sdk-go-v2/service/s3"
presignClient := s3.NewPresignClient(client)
presignResult, err := presignClient.PresignPutObject(context.TODO(),
&s3.PutObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("upload.txt"),
},
s3.WithPresignExpires(time.Hour),
)
fmt.Printf("Upload URL: %s\n", presignResult.URL)---
Rclone
Installation: https://rclone.org/install/
Configuration
Interactive:
rclone config
# n (new remote)
# name: r2
# Storage: Amazon S3 Compliant
# Provider: Cloudflare R2
# Enter credentialsManual (`~/.config/rclone/rclone.conf`):
[r2]
type = s3
provider = Cloudflare
access_key_id = your-access-key-id
secret_access_key = your-secret-access-key
endpoint = https://your-account-id.r2.cloudflarestorage.com
acl = privateFor object-level tokens:
[r2]
type = s3
provider = Cloudflare
access_key_id = your-access-key-id
secret_access_key = your-secret-access-key
endpoint = https://your-account-id.r2.cloudflarestorage.com
no_check_bucket = trueCommon Operations
List buckets:
rclone lsd r2:List objects:
rclone ls r2:my-bucket
rclone tree r2:my-bucketUpload file:
rclone copy file.txt r2:my-bucket/uploads/Upload directory:
rclone copy ./local-dir r2:my-bucket/remote-dir/ --progressUpload large files (optimized):
rclone copy large-video.mp4 r2:my-bucket/ \
--s3-upload-cutoff=100M \
--s3-chunk-size=100M \
--progressDownload file:
rclone copy r2:my-bucket/file.txt ./Sync directories:
# Local to R2
rclone sync ./local-dir r2:my-bucket/backup/
# R2 to local
rclone sync r2:my-bucket/backup/ ./local-dirDelete file:
rclone delete r2:my-bucket/file.txtGenerate presigned URL:
rclone link r2:my-bucket/file.txt --expire 3600Check differences:
rclone check ./local-dir r2:my-bucket/remote-dir/Mount R2 as filesystem (Linux/macOS):
rclone mount r2:my-bucket /mnt/r2 --daemonPerformance Tuning
rclone copy large-files/ r2:my-bucket/ \
--transfers=16 \
--checkers=32 \
--s3-chunk-size=100M \
--s3-upload-cutoff=100M \
--progress--transfers: Parallel file uploads--checkers: Parallel existence checks--s3-chunk-size: Multipart chunk size--s3-upload-cutoff: Multipart threshold
---
Terraform
Installation: https://www.terraform.io/downloads
Cloudflare Provider (Bucket Management)
main.tf:
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
variable "cloudflare_api_token" {
type = string
sensitive = true
}
variable "account_id" {
type = string
}
resource "cloudflare_r2_bucket" "main" {
account_id = var.account_id
name = "my-terraform-bucket"
location = "WNAM" # WNAM, ENAM, WEUR, EEUR, APAC
}
output "bucket_name" {
value = cloudflare_r2_bucket.main.name
}terraform.tfvars:
cloudflare_api_token = "your-cloudflare-api-token"
account_id = "your-account-id"Commands:
terraform init
terraform plan
terraform apply
terraform destroyAWS Provider (CORS, Lifecycle, Objects)
For CORS and lifecycle configuration, use AWS provider:
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "auto"
access_key = var.r2_access_key_id
secret_key = var.r2_secret_access_key
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
endpoints {
s3 = "https://${var.account_id}.r2.cloudflarestorage.com"
}
}
resource "aws_s3_bucket_cors_configuration" "main" {
bucket = "my-bucket"
cors_rule {
allowed_headers = ["*"]
allowed_methods = ["GET", "PUT", "POST"]
allowed_origins = ["https://example.com"]
expose_headers = ["ETag"]
max_age_seconds = 3600
}
}---
SDK Comparison
| SDK | Language | Presigned URLs | Multipart | Auto Multipart |
|---|---|---|---|---|
| AWS CLI | Shell | Yes (read) | Manual | Yes (>8MB) |
| JS v3 | JavaScript | Yes (both) | Manual | No |
| Boto3 | Python | Yes | Manual | Yes (configurable) |
| Go v2 | Go | Yes | Manual | No |
| Rclone | CLI | Yes (read) | Yes | Yes (configurable) |
| Terraform | HCL | N/A | N/A | N/A |
Best Practices
1. Use environment variables for credentials 2. Enable multipart for files >100MB 3. Implement retry logic with exponential backoff 4. Stream large files instead of buffering 5. Validate checksums for critical uploads 6. Use Rclone for bulk operations 7. Set reasonable timeouts for SDK clients 8. Handle errors gracefully with proper logging
Cloudflare R2 Workers Integration Patterns
Advanced patterns for integrating R2 with Cloudflare Workers.
Table of Contents
- Basic Integration
- File Upload Handlers
- Download & Streaming
- Image Processing
- Authentication & Authorization
- Caching Strategies
- Multipart Upload Workflows
- Event-Driven Patterns
- Error Handling
- Performance Optimization
---
Basic Integration
Binding Configuration
wrangler.toml:
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-15"
[[r2_buckets]]
binding = "UPLOADS"
bucket_name = "user-uploads-prod"
preview_bucket_name = "user-uploads-dev"
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "static-assets"TypeScript types:
interface Env {
UPLOADS: R2Bucket;
ASSETS: R2Bucket;
}---
File Upload Handlers
Simple Upload Handler
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response('No file uploaded', { status: 400 });
}
const key = `uploads/${crypto.randomUUID()}-${file.name}`;
await env.UPLOADS.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
},
customMetadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
});
return new Response(JSON.stringify({ key, size: file.size }), {
headers: { 'Content-Type': 'application/json' },
});
},
};Authenticated Upload with Validation
interface UploadRequest {
file: File;
userId: string;
token: string;
}
async function handleAuthenticatedUpload(
request: Request,
env: Env
): Promise<Response> {
// 1. Verify authentication
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.slice(7);
const userId = await verifyToken(token); // Your auth logic
// 2. Parse and validate file
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response('No file provided', { status: 400 });
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return new Response('Invalid file type', { status: 400 });
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
return new Response('File too large', { status: 413 });
}
// 3. Upload to R2
const key = `users/${userId}/${Date.now()}-${file.name}`;
await env.UPLOADS.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
cacheControl: 'private, max-age=0',
},
customMetadata: {
userId,
originalName: file.name,
uploadedAt: new Date().toISOString(),
ipAddress: request.headers.get('CF-Connecting-IP') || 'unknown',
},
});
// 4. Return success response
return new Response(
JSON.stringify({
success: true,
key,
url: `/files/${key}`,
size: file.size,
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
}Direct Upload with Presigned URL Pattern
// Generate upload URL (backend)
async function generateUploadUrl(
request: Request,
env: Env
): Promise<Response> {
const { fileName, fileType } = await request.json();
const key = `uploads/${crypto.randomUUID()}-${fileName}`;
// Store pending upload metadata
await env.KV.put(
`upload:${key}`,
JSON.stringify({ fileName, fileType, createdAt: Date.now() }),
{ expirationTtl: 3600 }
);
return new Response(
JSON.stringify({
uploadKey: key,
uploadUrl: `/api/upload/${key}`,
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
}
// Handle direct upload
async function handleDirectUpload(
request: Request,
env: Env,
key: string
): Promise<Response> {
// Verify upload key exists
const metadata = await env.KV.get(`upload:${key}`);
if (!metadata) {
return new Response('Invalid upload key', { status: 400 });
}
const { fileType } = JSON.parse(metadata);
// Upload to R2
await env.UPLOADS.put(key, request.body, {
httpMetadata: {
contentType: fileType,
},
});
// Clean up pending upload
await env.KV.delete(`upload:${key}`);
return new Response(JSON.stringify({ success: true, key }), {
headers: { 'Content-Type': 'application/json' },
});
}---
Download & Streaming
Basic Download Handler
async function handleDownload(
request: Request,
env: Env,
key: string
): Promise<Response> {
const object = await env.UPLOADS.get(key);
if (object === null) {
return new Response('File not found', { status: 404 });
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}Range Request Support (Video Streaming)
async function handleRangeRequest(
request: Request,
env: Env,
key: string
): Promise<Response> {
const rangeHeader = request.headers.get('Range');
if (!rangeHeader) {
// No range requested, return full file
const object = await env.UPLOADS.get(key);
if (!object) {
return new Response('Not found', { status: 404 });
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'video/mp4',
'Content-Length': object.size.toString(),
'Accept-Ranges': 'bytes',
},
});
}
// Parse range header (e.g., "bytes=0-1023")
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) {
return new Response('Invalid range', { status: 416 });
}
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : undefined;
// Get object with range
const object = await env.UPLOADS.get(key, {
range: end !== undefined
? { offset: start, length: end - start + 1 }
: { offset: start },
});
if (!object) {
return new Response('Not found', { status: 404 });
}
const actualEnd = end !== undefined ? end : object.size - 1;
return new Response(object.body, {
status: 206,
headers: {
'Content-Type': object.httpMetadata?.contentType || 'video/mp4',
'Content-Range': `bytes ${start}-${actualEnd}/${object.size}`,
'Content-Length': object.range.length.toString(),
'Accept-Ranges': 'bytes',
},
});
}Conditional Requests (If-None-Match)
async function handleConditionalRequest(
request: Request,
env: Env,
key: string
): Promise<Response> {
const ifNoneMatch = request.headers.get('If-None-Match');
const object = await env.UPLOADS.get(key, {
onlyIf: ifNoneMatch
? { etagDoesNotMatch: ifNoneMatch.replace(/"/g, '') }
: undefined,
});
if (object === null) {
// ETag matched, return 304
return new Response(null, {
status: 304,
headers: {
'ETag': ifNoneMatch || '',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
'Cache-Control': 'public, max-age=31536000',
},
});
}---
Image Processing
On-the-Fly Image Resizing
async function handleImageResize(
request: Request,
env: Env,
key: string
): Promise<Response> {
const url = new URL(request.url);
const width = parseInt(url.searchParams.get('w') || '0');
const height = parseInt(url.searchParams.get('h') || '0');
const quality = parseInt(url.searchParams.get('q') || '85');
const object = await env.UPLOADS.get(key);
if (!object) {
return new Response('Not found', { status: 404 });
}
// Use Cloudflare Image Resizing
const resizedResponse = await fetch(request.url, {
cf: {
image: {
width,
height,
quality,
format: 'auto',
},
},
});
return new Response(resizedResponse.body, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}Cached Thumbnail Generation
async function handleThumbnail(
request: Request,
env: Env,
key: string
): Promise<Response> {
const thumbnailKey = `thumbnails/${key}`;
// Check if thumbnail exists
let thumbnail = await env.ASSETS.get(thumbnailKey);
if (!thumbnail) {
// Generate thumbnail
const original = await env.UPLOADS.get(key);
if (!original) {
return new Response('Not found', { status: 404 });
}
// Resize using Cloudflare Images
const resized = await fetch(`https://your-domain.com/img/${key}`, {
cf: {
image: {
width: 200,
height: 200,
fit: 'cover',
quality: 80,
},
},
});
const thumbnailData = await resized.arrayBuffer();
// Cache thumbnail
await env.ASSETS.put(thumbnailKey, thumbnailData, {
httpMetadata: { contentType: 'image/webp' },
});
return new Response(thumbnailData, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000',
},
});
}
return new Response(thumbnail.body, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000',
},
});
}---
Authentication & Authorization
User-Scoped Access
async function handleUserFileAccess(
request: Request,
env: Env,
key: string
): Promise<Response> {
// 1. Extract user ID from token
const token = request.headers.get('Authorization')?.slice(7);
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
const userId = await verifyToken(token, env);
// 2. Check if user owns file
const object = await env.UPLOADS.head(key);
if (!object) {
return new Response('Not found', { status: 404 });
}
const fileUserId = object.customMetadata?.userId;
if (fileUserId !== userId) {
return new Response('Forbidden', { status: 403 });
}
// 3. Return file
const fileObject = await env.UPLOADS.get(key);
return new Response(fileObject!.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
},
});
}Signed Download URLs
async function generateSignedUrl(
env: Env,
key: string,
expiresIn: number
): Promise<string> {
const expiresAt = Date.now() + expiresIn * 1000;
const message = `${key}:${expiresAt}`;
// Sign with HMAC
const encoder = new TextEncoder();
const keyData = encoder.encode(env.SECRET_KEY);
const msgData = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const sigHex = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return `/files/${key}?expires=${expiresAt}&signature=${sigHex}`;
}
async function verifySignedUrl(
env: Env,
key: string,
expires: string,
signature: string
): Promise<boolean> {
const expiresAt = parseInt(expires);
if (Date.now() > expiresAt) {
return false;
}
const message = `${key}:${expiresAt}`;
const encoder = new TextEncoder();
const keyData = encoder.encode(env.SECRET_KEY);
const msgData = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const sigBytes = new Uint8Array(
signature.match(/.{2}/g)!.map(byte => parseInt(byte, 16))
);
return await crypto.subtle.verify('HMAC', cryptoKey, sigBytes, msgData);
}---
Caching Strategies
Cache API Integration
async function handleCachedDownload(
request: Request,
env: Env,
key: string
): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
// Check cache
let response = await cache.match(cacheKey);
if (response) {
return response;
}
// Fetch from R2
const object = await env.UPLOADS.get(key);
if (!object) {
return new Response('Not found', { status: 404 });
}
response = new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': object.httpEtag,
},
});
// Store in cache
await cache.put(cacheKey, response.clone());
return response;
}Smart Cache Invalidation
async function invalidateCache(
env: Env,
key: string
): Promise<void> {
const cache = caches.default;
// Invalidate main file
await cache.delete(`https://your-domain.com/files/${key}`);
// Invalidate related files (thumbnails, etc.)
await cache.delete(`https://your-domain.com/thumbnails/${key}`);
}
async function handleFileUpdate(
request: Request,
env: Env,
key: string
): Promise<Response> {
// Upload new version
await env.UPLOADS.put(key, request.body, {
httpMetadata: {
contentType: request.headers.get('Content-Type') || 'application/octet-stream',
},
});
// Invalidate cache
await invalidateCache(env, key);
return new Response('Updated', { status: 200 });
}---
Multipart Upload Workflows
Complete Multipart Upload Handler
interface MultipartSession {
uploadId: string;
key: string;
parts: Map<number, string>;
}
async function initializeMultipartUpload(
request: Request,
env: Env
): Promise<Response> {
const { fileName, fileType } = await request.json();
const key = `uploads/${crypto.randomUUID()}-${fileName}`;
const upload = await env.UPLOADS.createMultipartUpload(key, {
httpMetadata: { contentType: fileType },
});
// Store session in KV
await env.KV.put(
`multipart:${upload.uploadId}`,
JSON.stringify({ key, uploadId: upload.uploadId }),
{ expirationTtl: 86400 } // 24 hours
);
return new Response(
JSON.stringify({
uploadId: upload.uploadId,
key,
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
}
async function uploadPart(
request: Request,
env: Env
): Promise<Response> {
const { uploadId, partNumber } = await request.json();
// Get session
const sessionData = await env.KV.get(`multipart:${uploadId}`);
if (!sessionData) {
return new Response('Invalid upload ID', { status: 400 });
}
const { key } = JSON.parse(sessionData);
// Get multipart upload
const upload = env.UPLOADS.resumeMultipartUpload(key, uploadId);
// Upload part
const part = await upload.uploadPart(partNumber, request.body);
// Store part info
await env.KV.put(
`part:${uploadId}:${partNumber}`,
JSON.stringify({ partNumber, etag: part.etag }),
{ expirationTtl: 86400 }
);
return new Response(
JSON.stringify({
partNumber,
etag: part.etag,
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
}
async function completeMultipartUpload(
request: Request,
env: Env
): Promise<Response> {
const { uploadId, parts } = await request.json();
// Get session
const sessionData = await env.KV.get(`multipart:${uploadId}`);
if (!sessionData) {
return new Response('Invalid upload ID', { status: 400 });
}
const { key } = JSON.parse(sessionData);
// Complete upload
const upload = env.UPLOADS.resumeMultipartUpload(key, uploadId);
const object = await upload.complete(parts);
// Clean up session
await env.KV.delete(`multipart:${uploadId}`);
return new Response(
JSON.stringify({
key: object.key,
size: object.size,
etag: object.etag,
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
}---
Event-Driven Patterns
Event Notification Handler
interface R2EventMessage {
account: string;
bucket: string;
object: {
key: string;
size: number;
etag: string;
};
action: string;
eventTime: string;
}
export default {
async queue(
batch: MessageBatch<R2EventMessage>,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const event = message.body;
switch (event.action) {
case 'PutObject':
await handleNewUpload(event, env);
break;
case 'DeleteObject':
await handleDeletion(event, env);
break;
}
message.ack();
}
},
};
async function handleNewUpload(
event: R2EventMessage,
env: Env
): Promise<void> {
const { key } = event.object;
// Generate thumbnail if image
if (key.match(/\.(jpg|jpeg|png|webp)$/i)) {
await generateThumbnail(key, env);
}
// Log to analytics
await env.ANALYTICS.writeDataPoint({
indexes: [key],
blobs: [event.action],
doubles: [event.object.size],
});
}---
Error Handling
Comprehensive Error Handler
class R2Error extends Error {
constructor(
message: string,
public statusCode: number,
public code: string
) {
super(message);
}
}
async function handleR2Operation<T>(
operation: () => Promise<T>
): Promise<T> {
try {
return await operation();
} catch (error) {
if (error instanceof R2Error) {
throw error;
}
// Map R2 errors
if (error.message.includes('Object Not Found')) {
throw new R2Error('File not found', 404, 'NOT_FOUND');
}
if (error.message.includes('Bucket Not Found')) {
throw new R2Error('Bucket not found', 404, 'BUCKET_NOT_FOUND');
}
// Unknown error
console.error('R2 operation failed:', error);
throw new R2Error('Internal server error', 500, 'INTERNAL_ERROR');
}
}
async function handleRequest(
request: Request,
env: Env
): Promise<Response> {
try {
const result = await handleR2Operation(async () => {
return await env.UPLOADS.get('file.txt');
});
return new Response(result?.body);
} catch (error) {
if (error instanceof R2Error) {
return new Response(
JSON.stringify({
error: error.code,
message: error.message,
}),
{
status: error.statusCode,
headers: { 'Content-Type': 'application/json' },
}
);
}
return new Response('Internal error', { status: 500 });
}
}---
Performance Optimization
Parallel Operations
async function handleBatchDownload(
request: Request,
env: Env
): Promise<Response> {
const { keys } = await request.json<{ keys: string[] }>();
// Fetch all objects in parallel
const objects = await Promise.all(
keys.map(key => env.UPLOADS.get(key))
);
const files = objects
.filter(obj => obj !== null)
.map(obj => ({
key: obj!.key,
size: obj!.size,
contentType: obj!.httpMetadata?.contentType,
}));
return new Response(JSON.stringify(files), {
headers: { 'Content-Type': 'application/json' },
});
}Streaming Responses
async function handleLargeFileStream(
request: Request,
env: Env,
key: string
): Promise<Response> {
const object = await env.UPLOADS.get(key);
if (!object) {
return new Response('Not found', { status: 404 });
}
// Return stream directly (don't buffer)
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Content-Length': object.size.toString(),
},
});
}Best Practices Summary
1. Always stream large files - never buffer in memory 2. Use Workers Cache API for frequently accessed objects 3. Implement proper error handling with retries 4. Validate files before upload (type, size, content) 5. Use multipart uploads for files >100MB 6. Add comprehensive metadata for debugging 7. Implement authentication for private files 8. Use range requests for video streaming 9. Cache thumbnails separately from originals 10. Monitor performance with analytics