cloudflare-r2 by jezweb/claude-skills
npx skills add https://github.com/jezweb/claude-skills --skill cloudflare-r2状态 : 生产就绪 ✅ 最后更新 : 2026-01-20 依赖项 : cloudflare-worker-base (用于 Worker 设置) 最新版本 : wrangler@4.59.2, @cloudflare/workers-types@4.20260109.0, aws4fetch@1.0.20
近期更新 (2025) :
# 1. Create bucket
npx wrangler r2 bucket create my-bucket
# 2. Add binding to wrangler.jsonc
# {
# "r2_buckets": [{
# "binding": "MY_BUCKET",
# "bucket_name": "my-bucket",
# "preview_bucket_name": "my-bucket-preview" // Optional: separate dev/prod
# }]
# }
# 3. Upload/download from Worker
type Bindings = { MY_BUCKET: R2Bucket };
// Upload
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: { contentType: 'text/plain' }
});
// Download
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
},
});
# 4. Deploy
npx wrangler deploy
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// put() - Upload objects
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: {
contentType: 'text/plain',
cacheControl: 'public, max-age=3600',
},
customMetadata: { userId: '123' },
md5: await crypto.subtle.digest('MD5', data), // Checksum verification
});
// Conditional upload (prevent overwrites)
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: { uploadedBefore: new Date('2020-01-01') }
});
if (!object) return c.json({ error: 'File already exists' }, 409);
// get() - Download objects
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);
const text = await object.text(); // As string
const json = await object.json(); // As JSON
const buffer = await object.arrayBuffer(); // As ArrayBuffer
// Range requests (partial downloads)
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 1024 * 1024 } // First 1MB
});
// head() - Get metadata only (no body download)
const object = await env.MY_BUCKET.head('file.txt');
console.log(object.size, object.etag, object.customMetadata);
// delete() - Delete objects
await env.MY_BUCKET.delete('file.txt'); // Single delete (idempotent)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt']); // Bulk delete (max 1000)
// list() - List objects
const listed = await env.MY_BUCKET.list({
prefix: 'images/', // Filter by prefix
limit: 100,
cursor: cursor, // Pagination
delimiter: '/', // Folder-like listing
include: ['httpMetadata', 'customMetadata'], // IMPORTANT: Opt-in for metadata
});
for (const object of listed.objects) {
console.log(`${object.key}: ${object.size} bytes`);
console.log(object.httpMetadata?.contentType); // Now populated with include parameter
console.log(object.customMetadata); // Now populated with include parameter
}
适用于大于 100MB 的文件或可恢复的上传。在以下情况使用:大文件、浏览器上传、需要并行处理。
// 1. Create multipart upload
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', {
httpMetadata: { contentType: 'application/zip' }
});
// 2. Upload parts (5MB-100MB each, max 10,000 parts)
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const part1 = await multipart.uploadPart(1, chunk1);
const part2 = await multipart.uploadPart(2, chunk2);
// 3. Complete upload
const object = await multipart.complete([
{ partNumber: 1, etag: part1.etag },
{ partNumber: 2, etag: part2.etag },
]);
// 4. Abort if needed
await multipart.abort();
限制 : 分片大小 5MB-100MB,每次上传最多 10,000 个分片。不要用于小于 5MB 的文件 (开销过大)。
允许客户端直接上传/下载到/从 R2 (绕过 Worker)。使用 aws4fetch 库。
import { AwsClient } from 'aws4fetch';
const r2Client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 hour expiry
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }), // or 'GET' for downloads
{ aws: { signQuery: true } }
);
// Client uploads directly to R2
await fetch(signed.url, { method: 'PUT', body: file });
关键安全事项:
users/${userId}/${filename}关键 : 预签名 URL 仅适用于 S3 API 域名,不适用于自定义域名。
// ❌ WRONG - Presigned URLs don't work with custom domains
const url = new URL(`https://cdn.example.com/${filename}`);
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
// This URL will fail - presigning requires S3 domain
// ✅ CORRECT - Use R2 storage domain for presigned URLs
const url = new URL(
`https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
// Pattern: Upload via presigned S3 URL, serve via custom domain
async function generateUploadUrl(filename: string) {
const uploadUrl = new URL(
`https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
new Request(uploadUrl, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return {
uploadUrl: signed.url, // For client upload (S3 domain)
publicUrl: `https://cdn.example.com/${filename}` // For serving (custom domain)
};
}
来源 : 社区知识
⚠️ Wrangler CLI 需要 "Admin Read & Write" 权限,而不是 "Object Read & Write"。
为 wrangler 操作创建 API 令牌时:
原因 : "Object Read & Write" 仅用于直接访问 S3 API。Wrangler 需要管理员级别的权限来执行桶操作。
# With wrong permissions:
export CLOUDFLARE_API_TOKEN="token_with_object_readwrite"
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
# ✘ [ERROR] Failed to fetch - 403: Forbidden
# With correct permissions (Admin Read & Write):
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
# ✔ Success
来源 : GitHub Issue #9235
在浏览器访问之前,在桶设置中配置 CORS (控制台 → R2 → Bucket → Settings → CORS Policy)。
⚠️ wrangler CLI 和控制台 UI 使用不同的 CORS 格式。这通常会引起混淆。
控制台格式 (仅在 UI 中有效):
[{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
CLI 格式 (wrangler r2 bucket cors 命令所需):
{
"rules": [{
"allowed": {
"origins": ["https://www.example.com"],
"methods": ["GET", "PUT"],
"headers": ["Content-Type", "Authorization"]
},
"exposeHeaders": ["ETag", "Content-Length"],
"maxAgeSeconds": 8640
}]
}
# Using CLI format
wrangler r2 bucket cors set my-bucket --file cors-config.json
# Error if using Dashboard format:
# "The CORS configuration file must contain a 'rules' array"
来源 : GitHub Issue #10076
当对 R2 使用自定义域名时,CORS 在 两个层面 处理:
// Bucket CORS (set via dashboard or wrangler)
{
"rules": [{
"allowed": {
"origins": ["https://app.example.com"],
"methods": ["GET", "PUT"],
"headers": ["Content-Type"]
},
"maxAgeSeconds": 3600
}]
}
// Additional CORS via Transform Rules (Dashboard → Rules → Transform Rules)
// Modify Response Header: Access-Control-Allow-Origin: https://app.example.com
// Order of CORS evaluation:
// 1. R2 bucket CORS (if presigned URL or direct R2 access)
// 2. Transform Rules CORS (if via custom domain)
来源 : 社区知识
对于预签名 URL : CORS 由 R2 直接处理 (在桶上配置,而不是 Worker)。
// HTTP metadata (standard headers)
await env.MY_BUCKET.put('file.pdf', data, {
httpMetadata: {
contentType: 'application/pdf',
cacheControl: 'public, max-age=31536000, immutable',
contentDisposition: 'attachment; filename="report.pdf"',
contentEncoding: 'gzip',
},
customMetadata: {
userId: '12345',
version: '1.0',
} // Max 2KB total, keys/values must be strings
});
// Read metadata
const object = await env.MY_BUCKET.head('file.pdf');
console.log(object.httpMetadata, object.customMetadata);
try {
await env.MY_BUCKET.put(key, data);
} catch (error: any) {
const message = error.message;
if (message.includes('R2_ERROR')) {
// Generic R2 error
} else if (message.includes('exceeded')) {
// Quota exceeded
} else if (message.includes('precondition')) {
// Conditional operation failed
} else if (message.includes('multipart')) {
// Multipart upload error
}
console.error('R2 Error:', message);
return c.json({ error: 'Storage operation failed' }, 500);
}
R2 在 2025 年第一季度经历了两次重大中断 (2月6日: 59 分钟,3月21日: 1小时7分钟),原因是操作问题。针对平台错误,请实现具有指数退避的健壮重试逻辑。
async function r2WithRetry<T>(
operation: () => Promise<T>,
maxRetries = 5
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
const message = error.message;
// Retry on transient errors and platform issues
const is5xxError =
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504');
const isRetryable =
is5xxError ||
message.includes('network') ||
message.includes('timeout') ||
message.includes('temporarily unavailable');
if (!isRetryable || attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff (longer for platform errors)
// 5xx errors: 1s, 2s, 4s, 8s, 16s (up to 31s total)
// Other errors: 1s, 2s, 4s, 5s, 5s (up to 17s total)
const delay = is5xxError
? Math.min(1000 * Math.pow(2, attempt), 16000)
: Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
// Usage
const object = await r2WithRetry(() =>
env.MY_BUCKET.get('important-file.txt')
);
平台可靠性 : 虽然 R2 通常可靠,但 2025 年第一季度的中断表明,对于生产应用程序来说,实现重试逻辑非常重要。所有 5xx 错误都应使用指数退避进行重试。
来源 :
// Batch delete (up to 1000 keys)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']);
// Range requests for large files
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 10 * 1024 * 1024 } // First 10MB
});
// Cache headers for immutable assets
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
httpMetadata: { cacheControl: 'public, max-age=31536000, immutable' }
});
// Checksums for data integrity
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('important.dat', fileData, { md5: md5Hash });
⚠️ 高频并发写入同一对象键将触发 HTTP 429 速率限制。
// ❌ BAD: Multiple Workers writing to same key rapidly
async function logToSharedFile(env: Env, logEntry: string) {
const existing = await env.LOGS.get('global-log.txt');
const content = (await existing?.text()) || '';
await env.LOGS.put('global-log.txt', content + logEntry);
// High write frequency to same key = 429 errors
}
// ✅ GOOD: Shard by timestamp or ID (distribute writes)
async function logWithSharding(env: Env, logEntry: string) {
const timestamp = Date.now();
const shard = Math.floor(timestamp / 60000); // 1-minute shards
await env.LOGS.put(`logs/${shard}.txt`, logEntry, {
customMetadata: { timestamp: timestamp.toString() }
});
// Different keys = no rate limiting
}
// ✅ ALTERNATIVE: Use Durable Objects for append operations
// Durable Objects can handle high-frequency updates to same state
// ✅ ALTERNATIVE: Use Queues + batch processing
// Buffer writes and batch them with unique keys
来源 : R2 限制文档
🚨 关键 : {bucket}.{account}.r2.cloudflarestorage.com (r2.dev) 域名 不适用于生产环境。
r2.dev 限制:
对于生产环境:始终使用自定义域名
// ❌ NOT for production - r2.dev endpoint
const publicUrl = `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${key}`;
// This will be rate limited in production
// ✅ Production: Custom domain
const productionUrl = `https://cdn.example.com/${key}`;
// Setup custom domain:
// 1. Dashboard → R2 → Bucket → Settings → Custom Domains
// 2. Add your domain (e.g., cdn.example.com)
// 3. Benefits:
// - No rate limiting beyond account limits
// - Cloudflare Cache support
// - Custom cache rules via Workers
// - Full CDN features
r2.dev 仅用于测试/开发。生产环境必须使用自定义域名。
来源 : R2 限制文档
始终要做:
contentTypehead()list() 的 include 参数获取元数据切勿做:
contentType (文件会作为二进制下载)随着桶限制增加到 每个账户 100 万个桶,现在对于大规模应用程序来说,每个租户一个桶的方案是可行的。
// Option 1: Per-tenant buckets (now scalable to 1M tenants)
const bucketName = `tenant-${tenantId}`;
const bucket = env[bucketName]; // Dynamic binding
// Option 2: Key prefixing (still preferred for most use cases)
await env.MY_BUCKET.put(`tenants/${tenantId}/file.txt`, data);
// Choose based on:
// - Per-tenant buckets: Strong isolation, separate billing/quotas
// - Key prefixing: Simpler, fewer resources, easier to manage
来源 : R2 限制文档
此技能可预防 13 个已记录的问题:
| 问题编号 | 问题 | 错误 | 预防措施 |
|---|---|---|---|
| #1 | 浏览器中的 CORS 错误 | 浏览器无法上传/下载 | 在桶设置中配置 CORS,使用正确的 CLI 格式 |
| #2 | 文件作为二进制下载 | 缺少 content-type | 上传时始终设置 httpMetadata.contentType |
| #3 | 预签名 URL 过期 | URL 永不过期 | 始终设置 X-Amz-Expires (1-24 小时) |
| #4 | 分片上传限制 | 分片超出限制 | 保持分片大小 5MB-100MB,最多 10,000 个分片 |
| #5 | 批量删除限制 | 超过 1000 个键失败 | 将删除操作分块为每批 1000 个 |
| #6 | 自定义元数据溢出 | 超过 2KB 限制 | 保持自定义元数据小于 2KB |
| #7 | list() 元数据缺失 | httpMetadata 未定义 | 使用 include: ['httpMetadata', 'customMetadata'] 参数 (Issue #10870) |
| #8 | CORS 格式混淆 | "必须包含 'rules' 数组" | 对 wrangler 使用带有 rules 包装器的 CLI 格式 (Issue #10076) |
| #9 | API 令牌 403 错误 | "Failed to fetch - 403" | 对 wrangler 使用 "Admin Read & Write" 而不是 "Object Read & Write" (Issue #9235) |
| #10 | r2.dev 速率限制 | 生产环境中出现 HTTP 429 | 使用自定义域名,生产环境切勿使用 r2.dev (R2 Limits) |
| #11 | 并发写入 429 错误 | 同一键频繁写入 | 跨不同键分片写入 (R2 Limits) |
| #12 | 预签名 URL 域名错误 | 预签名 URL 失败 | 仅使用 S3 域名,而非自定义域名 (Community) |
| #13 | 平台中断 | 中断期间的 5xx 错误 | 实现具有指数退避的重试逻辑 (Feb 6, Mar 21) |
⚠️ 本地 R2 DELETE 操作不会清理 blob 文件。当使用 wrangler dev 时,已删除的对象仍保留在 .wrangler/state/v3/r2/{bucket-name}/blobs/ 中,导致本地存储无限增长。
# Symptom: .wrangler/state grows large during development
du -sh .wrangler/state/v3/r2/
# Fix: Manually cleanup local R2 storage
rm -rf .wrangler/state/v3/r2/
# Alternative: Use remote R2 for development
wrangler dev --remote
来源 : GitHub Issue #10795
⚠️ 使用 --remote 的本地开发可能存在不可靠的 .get() 操作。一些用户报告 get() 返回 undefined,尽管 put() 工作正常。
# If experiencing issues with remote R2 in local dev:
# Option 1: Use local buckets instead (recommended)
wrangler dev # No --remote flag
# Option 2: Deploy to preview environment for testing
wrangler deploy --env preview
# Option 3: Add retry logic if must use --remote
async function safeGet(bucket: R2Bucket, key: string) {
for (let i = 0; i < 3; i++) {
const obj = await bucket.get(key);
if (obj && obj.body) return obj;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Failed to get object after retries');
}
来源 : GitHub Issue #8868 (社区来源)
# Bucket management
wrangler r2 bucket create <BUCKET_NAME>
wrangler r2 bucket list
wrangler r2 bucket delete <BUCKET_NAME>
# Object management
wrangler r2 object put <BUCKET_NAME>/<KEY> --file=<FILE_PATH>
wrangler r2 object get <BUCKET_NAME>/<KEY> --file=<OUTPUT_PATH>
wrangler r2 object delete <BUCKET_NAME>/<KEY>
# List objects
wrangler r2 object list <BUCKET_NAME>
wrangler r2 object list <BUCKET_NAME> --prefix="folder/"
准备好使用 R2 存储了! 🚀
最后验证 : 2026-01-20 | 技能版本 : 2.0.0 | 变更 : 根据社区研究新增 7 个已知问题 (list() 元数据、CORS 格式混淆、API 令牌权限、r2.dev 速率限制、并发写入限制、预签名 URL 域名要求、平台中断重试模式)。增强了 5xx 错误的重试逻辑,新增开发最佳实践部分,记录了桶限制增加到 1M。
每周安装数
462
仓库
GitHub 星标数
650
首次出现
Jan 20, 2026
安全审计
安装在
claude-code357
opencode316
gemini-cli314
codex282
antigravity260
cursor254
Status : Production Ready ✅ Last Updated : 2026-01-20 Dependencies : cloudflare-worker-base (for Worker setup) Latest Versions : wrangler@4.59.2, @cloudflare/workers-types@4.20260109.0, aws4fetch@1.0.20
Recent Updates (2025) :
# 1. Create bucket
npx wrangler r2 bucket create my-bucket
# 2. Add binding to wrangler.jsonc
# {
# "r2_buckets": [{
# "binding": "MY_BUCKET",
# "bucket_name": "my-bucket",
# "preview_bucket_name": "my-bucket-preview" // Optional: separate dev/prod
# }]
# }
# 3. Upload/download from Worker
type Bindings = { MY_BUCKET: R2Bucket };
// Upload
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: { contentType: 'text/plain' }
});
// Download
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
},
});
# 4. Deploy
npx wrangler deploy
// put() - Upload objects
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: {
contentType: 'text/plain',
cacheControl: 'public, max-age=3600',
},
customMetadata: { userId: '123' },
md5: await crypto.subtle.digest('MD5', data), // Checksum verification
});
// Conditional upload (prevent overwrites)
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: { uploadedBefore: new Date('2020-01-01') }
});
if (!object) return c.json({ error: 'File already exists' }, 409);
// get() - Download objects
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);
const text = await object.text(); // As string
const json = await object.json(); // As JSON
const buffer = await object.arrayBuffer(); // As ArrayBuffer
// Range requests (partial downloads)
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 1024 * 1024 } // First 1MB
});
// head() - Get metadata only (no body download)
const object = await env.MY_BUCKET.head('file.txt');
console.log(object.size, object.etag, object.customMetadata);
// delete() - Delete objects
await env.MY_BUCKET.delete('file.txt'); // Single delete (idempotent)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt']); // Bulk delete (max 1000)
// list() - List objects
const listed = await env.MY_BUCKET.list({
prefix: 'images/', // Filter by prefix
limit: 100,
cursor: cursor, // Pagination
delimiter: '/', // Folder-like listing
include: ['httpMetadata', 'customMetadata'], // IMPORTANT: Opt-in for metadata
});
for (const object of listed.objects) {
console.log(`${object.key}: ${object.size} bytes`);
console.log(object.httpMetadata?.contentType); // Now populated with include parameter
console.log(object.customMetadata); // Now populated with include parameter
}
For files >100MB or resumable uploads. Use when: large files, browser uploads, parallelization needed.
// 1. Create multipart upload
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', {
httpMetadata: { contentType: 'application/zip' }
});
// 2. Upload parts (5MB-100MB each, max 10,000 parts)
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const part1 = await multipart.uploadPart(1, chunk1);
const part2 = await multipart.uploadPart(2, chunk2);
// 3. Complete upload
const object = await multipart.complete([
{ partNumber: 1, etag: part1.etag },
{ partNumber: 2, etag: part2.etag },
]);
// 4. Abort if needed
await multipart.abort();
Limits : Parts 5MB-100MB, max 10,000 parts per upload. Don't use for files <5MB (overhead).
Allow clients to upload/download directly to/from R2 (bypasses Worker). Use aws4fetch library.
import { AwsClient } from 'aws4fetch';
const r2Client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 hour expiry
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }), // or 'GET' for downloads
{ aws: { signQuery: true } }
);
// Client uploads directly to R2
await fetch(signed.url, { method: 'PUT', body: file });
CRITICAL Security:
users/${userId}/${filename}CRITICAL : Presigned URLs ONLY work with the S3 API domain , not custom domains.
// ❌ WRONG - Presigned URLs don't work with custom domains
const url = new URL(`https://cdn.example.com/${filename}`);
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
// This URL will fail - presigning requires S3 domain
// ✅ CORRECT - Use R2 storage domain for presigned URLs
const url = new URL(
`https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
// Pattern: Upload via presigned S3 URL, serve via custom domain
async function generateUploadUrl(filename: string) {
const uploadUrl = new URL(
`https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
new Request(uploadUrl, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return {
uploadUrl: signed.url, // For client upload (S3 domain)
publicUrl: `https://cdn.example.com/${filename}` // For serving (custom domain)
};
}
Source : Community Knowledge
⚠️ Wrangler CLI requires "Admin Read & Write" permissions, not "Object Read & Write".
When creating API tokens for wrangler operations:
Why : "Object Read & Write" is for S3 API direct access only. Wrangler needs admin-level permissions for bucket operations.
# With wrong permissions:
export CLOUDFLARE_API_TOKEN="token_with_object_readwrite"
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
# ✘ [ERROR] Failed to fetch - 403: Forbidden
# With correct permissions (Admin Read & Write):
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
# ✔ Success
Source : GitHub Issue #9235
Configure CORS in bucket settings (Dashboard → R2 → Bucket → Settings → CORS Policy) before browser access.
⚠️ The wrangler CLI and Dashboard UI use DIFFERENT CORS formats. This commonly causes confusion.
Dashboard Format (works in UI only):
[{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
CLI Format (required for wrangler r2 bucket cors):
{
"rules": [{
"allowed": {
"origins": ["https://www.example.com"],
"methods": ["GET", "PUT"],
"headers": ["Content-Type", "Authorization"]
},
"exposeHeaders": ["ETag", "Content-Length"],
"maxAgeSeconds": 8640
}]
}
# Using CLI format
wrangler r2 bucket cors set my-bucket --file cors-config.json
# Error if using Dashboard format:
# "The CORS configuration file must contain a 'rules' array"
Source : GitHub Issue #10076
When using custom domains with R2, CORS is handled in two layers :
// Bucket CORS (set via dashboard or wrangler)
{
"rules": [{
"allowed": {
"origins": ["https://app.example.com"],
"methods": ["GET", "PUT"],
"headers": ["Content-Type"]
},
"maxAgeSeconds": 3600
}]
}
// Additional CORS via Transform Rules (Dashboard → Rules → Transform Rules)
// Modify Response Header: Access-Control-Allow-Origin: https://app.example.com
// Order of CORS evaluation:
// 1. R2 bucket CORS (if presigned URL or direct R2 access)
// 2. Transform Rules CORS (if via custom domain)
Source : Community Knowledge
For presigned URLs : CORS handled by R2 directly (configure on bucket, not Worker).
// HTTP metadata (standard headers)
await env.MY_BUCKET.put('file.pdf', data, {
httpMetadata: {
contentType: 'application/pdf',
cacheControl: 'public, max-age=31536000, immutable',
contentDisposition: 'attachment; filename="report.pdf"',
contentEncoding: 'gzip',
},
customMetadata: {
userId: '12345',
version: '1.0',
} // Max 2KB total, keys/values must be strings
});
// Read metadata
const object = await env.MY_BUCKET.head('file.pdf');
console.log(object.httpMetadata, object.customMetadata);
try {
await env.MY_BUCKET.put(key, data);
} catch (error: any) {
const message = error.message;
if (message.includes('R2_ERROR')) {
// Generic R2 error
} else if (message.includes('exceeded')) {
// Quota exceeded
} else if (message.includes('precondition')) {
// Conditional operation failed
} else if (message.includes('multipart')) {
// Multipart upload error
}
console.error('R2 Error:', message);
return c.json({ error: 'Storage operation failed' }, 500);
}
R2 experienced two major outages in Q1 2025 (February 6: 59 minutes, March 21: 1h 7min) due to operational issues. Implement robust retry logic with exponential backoff for platform errors.
async function r2WithRetry<T>(
operation: () => Promise<T>,
maxRetries = 5
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
const message = error.message;
// Retry on transient errors and platform issues
const is5xxError =
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504');
const isRetryable =
is5xxError ||
message.includes('network') ||
message.includes('timeout') ||
message.includes('temporarily unavailable');
if (!isRetryable || attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff (longer for platform errors)
// 5xx errors: 1s, 2s, 4s, 8s, 16s (up to 31s total)
// Other errors: 1s, 2s, 4s, 5s, 5s (up to 17s total)
const delay = is5xxError
? Math.min(1000 * Math.pow(2, attempt), 16000)
: Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
// Usage
const object = await r2WithRetry(() =>
env.MY_BUCKET.get('important-file.txt')
);
Platform Reliability : While R2 is generally reliable, the 2025 Q1 outages demonstrate the importance of retry logic for production applications. All 5xx errors should be retried with exponential backoff.
Sources :
// Batch delete (up to 1000 keys)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']);
// Range requests for large files
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 10 * 1024 * 1024 } // First 10MB
});
// Cache headers for immutable assets
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
httpMetadata: { cacheControl: 'public, max-age=31536000, immutable' }
});
// Checksums for data integrity
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('important.dat', fileData, { md5: md5Hash });
⚠️ High-frequency concurrent writes to the same object key will trigger HTTP 429 rate limiting.
// ❌ BAD: Multiple Workers writing to same key rapidly
async function logToSharedFile(env: Env, logEntry: string) {
const existing = await env.LOGS.get('global-log.txt');
const content = (await existing?.text()) || '';
await env.LOGS.put('global-log.txt', content + logEntry);
// High write frequency to same key = 429 errors
}
// ✅ GOOD: Shard by timestamp or ID (distribute writes)
async function logWithSharding(env: Env, logEntry: string) {
const timestamp = Date.now();
const shard = Math.floor(timestamp / 60000); // 1-minute shards
await env.LOGS.put(`logs/${shard}.txt`, logEntry, {
customMetadata: { timestamp: timestamp.toString() }
});
// Different keys = no rate limiting
}
// ✅ ALTERNATIVE: Use Durable Objects for append operations
// Durable Objects can handle high-frequency updates to same state
// ✅ ALTERNATIVE: Use Queues + batch processing
// Buffer writes and batch them with unique keys
Source : R2 Limits Documentation
🚨 CRITICAL : The {bucket}.{account}.r2.cloudflarestorage.com (r2.dev) domain is NOT for production use.
r2.dev limitations:
For production: ALWAYS use custom domains
// ❌ NOT for production - r2.dev endpoint
const publicUrl = `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${key}`;
// This will be rate limited in production
// ✅ Production: Custom domain
const productionUrl = `https://cdn.example.com/${key}`;
// Setup custom domain:
// 1. Dashboard → R2 → Bucket → Settings → Custom Domains
// 2. Add your domain (e.g., cdn.example.com)
// 3. Benefits:
// - No rate limiting beyond account limits
// - Cloudflare Cache support
// - Custom cache rules via Workers
// - Full CDN features
r2.dev is ONLY for testing/development. Custom domains are required for production.
Source : R2 Limits Documentation
Always Do:
contentType for all uploadshead() when you only need metadatainclude parameter with list() to get metadataNever Do:
contentType (files download as binary)With the bucket limit increased to 1 million buckets per account , per-tenant buckets are now viable for large-scale applications.
// Option 1: Per-tenant buckets (now scalable to 1M tenants)
const bucketName = `tenant-${tenantId}`;
const bucket = env[bucketName]; // Dynamic binding
// Option 2: Key prefixing (still preferred for most use cases)
await env.MY_BUCKET.put(`tenants/${tenantId}/file.txt`, data);
// Choose based on:
// - Per-tenant buckets: Strong isolation, separate billing/quotas
// - Key prefixing: Simpler, fewer resources, easier to manage
Source : R2 Limits Documentation
This skill prevents 13 documented issues:
| Issue # | Issue | Error | Prevention |
|---|---|---|---|
| #1 | CORS errors in browser | Browser can't upload/download | Configure CORS in bucket settings, use correct CLI format |
| #2 | Files download as binary | Missing content-type | Always set httpMetadata.contentType on upload |
| #3 | Presigned URL expiry | URLs never expire | Always set X-Amz-Expires (1-24 hours) |
| #4 | Multipart upload limits | Parts exceed limits |
⚠️ Local R2 DELETE operations don't cleanup blob files. When using wrangler dev, deleted objects remain in .wrangler/state/v3/r2/{bucket-name}/blobs/, causing local storage to grow indefinitely.
# Symptom: .wrangler/state grows large during development
du -sh .wrangler/state/v3/r2/
# Fix: Manually cleanup local R2 storage
rm -rf .wrangler/state/v3/r2/
# Alternative: Use remote R2 for development
wrangler dev --remote
Source : GitHub Issue #10795
⚠️ Local dev with--remote can have unreliable .get() operations. Some users report get() returning undefined despite put() working correctly.
# If experiencing issues with remote R2 in local dev:
# Option 1: Use local buckets instead (recommended)
wrangler dev # No --remote flag
# Option 2: Deploy to preview environment for testing
wrangler deploy --env preview
# Option 3: Add retry logic if must use --remote
async function safeGet(bucket: R2Bucket, key: string) {
for (let i = 0; i < 3; i++) {
const obj = await bucket.get(key);
if (obj && obj.body) return obj;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Failed to get object after retries');
}
Source : GitHub Issue #8868 (Community-sourced)
# Bucket management
wrangler r2 bucket create <BUCKET_NAME>
wrangler r2 bucket list
wrangler r2 bucket delete <BUCKET_NAME>
# Object management
wrangler r2 object put <BUCKET_NAME>/<KEY> --file=<FILE_PATH>
wrangler r2 object get <BUCKET_NAME>/<KEY> --file=<OUTPUT_PATH>
wrangler r2 object delete <BUCKET_NAME>/<KEY>
# List objects
wrangler r2 object list <BUCKET_NAME>
wrangler r2 object list <BUCKET_NAME> --prefix="folder/"
Ready to store with R2! 🚀
Last verified : 2026-01-20 | Skill version : 2.0.0 | Changes : Added 7 new known issues from community research (list() metadata, CORS format confusion, API token permissions, r2.dev rate limiting, concurrent write limits, presigned URL domain requirements, platform outage retry patterns). Enhanced retry logic for 5xx errors, added development best practices section, documented bucket limit increase to 1M.
Weekly Installs
462
Repository
GitHub Stars
650
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubWarnSocketPassSnykPass
Installed on
claude-code357
opencode316
gemini-cli314
codex282
antigravity260
cursor254
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
106,200 周安装
竞争对手研究指南:SEO、内容、反向链接与定价分析工具
231 周安装
Azure 工作负载自动升级评估工具 - 支持 Functions、App Service 计划与 SKU 迁移
231 周安装
Kaizen持续改进方法论:软件开发中的渐进式优化与防错设计实践指南
231 周安装
软件UI/UX设计指南:以用户为中心的设计原则、WCAG可访问性与平台规范
231 周安装
Apify 网络爬虫和自动化平台 - 无需编码抓取亚马逊、谷歌、领英等网站数据
231 周安装
llama.cpp 中文指南:纯 C/C++ LLM 推理,CPU/非 NVIDIA 硬件优化部署
231 周安装
| Keep parts 5MB-100MB, max 10,000 parts |
| #5 | Bulk delete limits | >1000 keys fails | Chunk deletes into batches of 1000 |
| #6 | Custom metadata overflow | Exceeds 2KB limit | Keep custom metadata under 2KB |
| #7 | list() metadata missing | httpMetadata undefined | Use include: ['httpMetadata', 'customMetadata'] parameter (Issue #10870) |
| #8 | CORS format confusion | "Must contain 'rules' array" | Use CLI format with rules wrapper for wrangler (Issue #10076) |
| #9 | API token 403 errors | "Failed to fetch - 403" | Use "Admin Read & Write" not "Object Read & Write" for wrangler (Issue #9235) |
| #10 | r2.dev rate limiting | HTTP 429 in production | Use custom domains, never r2.dev for production (R2 Limits) |
| #11 | Concurrent write 429s | Same key written frequently | Shard writes across different keys (R2 Limits) |
| #12 | Presigned URL domain error | Presigned URLs fail | Use S3 domain only, not custom domains (Community) |
| #13 | Platform outages | 5xx errors during outages | Implement retry logic with exponential backoff (Feb 6, Mar 21) |