← All posts
·17 min read

Claude Code with Cloudflare R2: Object Storage at the Edge

Claude CodeCloudflare R2StorageEdge
Claude Code with Cloudflare R2: Object Storage at the Edge

Why R2 without CLAUDE.md generates S3-shaped code that misses the binding model

R2 has two completely different API surfaces and most developers using Claude Code on Cloudflare projects hit the wrong one first. Claude Code is trained on a large body of AWS S3 code. When you ask it to "add file upload to this Worker", the path of least resistance is new S3Client({ region: 'us-east-1' }) with the standard @aws-sdk/client-s3 package, a PutObjectCommand, and an IAM credential pair. That code will compile. It will also fail silently or throw cryptic auth errors because R2 bindings inside Workers do not go through the S3 HTTP API at all.

Inside a Cloudflare Worker, R2 is available as a binding on the env object. There are no HTTP calls. There are no credentials. There is no SDK import. You call await env.MY_BUCKET.put(key, value) directly, and the runtime handles everything. The S3-compatible API exists, but it is designed for clients outside the Workers runtime: SDKs running in Node.js, Python scripts, or browser-side code that needs presigned URLs. Using @aws-sdk/client-s3 inside a Worker adds network latency and an auth layer that does not need to exist.

Claude Code also gets the S3 SDK configuration wrong for R2 even when the S3 path is correct. The region must be 'auto', not 'us-east-1' or any other AWS region string. The endpoint must be https://{account-id}.r2.cloudflarestorage.com. Without these two constraints in CLAUDE.md, Claude generates S3 client config that points at AWS infrastructure rather than Cloudflare's.

This guide covers the CLAUDE.md configuration that anchors Claude Code to R2's actual model: the native Workers binding for in-Worker operations, the S3 SDK correctly pointed at the R2 endpoint for external access, and the specific rules that prevent the four failure modes Claude hits most often. If you are new to Cloudflare Workers development with Claude Code, Claude Code with Cloudflare Workers covers the foundational setup including wrangler configuration, environment types, and the env object pattern. For D1, Cloudflare's SQL database, Claude Code with Cloudflare D1 covers the same approach applied to the D1 binding.

The R2 CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a project that uses Cloudflare R2, it needs to declare the binding name and bucket name from wrangler.toml, the two API surfaces and when to use each, the correct S3 SDK configuration for the R2 endpoint, the httpMetadata.contentType requirement, list pagination behaviour, and the hard rules that block the patterns Claude generates most often without guidance.

# Cloudflare R2 rules

## Stack
- Cloudflare Workers (TypeScript, strict)
- R2 for object storage
- wrangler 3.x
- @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner for external/signed URL access only

## Binding declaration (wrangler.toml)
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-production-bucket"

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-dev-bucket"
environment = "development"

## Two API surfaces - use the right one

### Inside a Worker: use the binding (env.MY_BUCKET)
- NEVER import @aws-sdk inside a Worker handler when a binding is available
- NEVER call the S3-compatible HTTP API from inside a Worker
- The binding is zero-latency, zero-auth, zero-config
- env.MY_BUCKET is typed as R2Bucket by wrangler-generated types

### Outside a Worker (Node.js, scripts, presigned URLs): use the S3 SDK
- ALWAYS use region: 'auto' (NOT 'us-east-1' or any AWS region)
- ALWAYS set endpoint: 'https://{ACCOUNT_ID}.r2.cloudflarestorage.com'
- ALWAYS use R2 API tokens (not AWS credentials)
- Import from @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner

## Workers binding API (env.MY_BUCKET)

### Put
await env.MY_BUCKET.put('path/to/object.png', body, {
  httpMetadata: { contentType: 'image/png' },
});
// body: ReadableStream | ArrayBuffer | string | Blob | null

### Get
const obj = await env.MY_BUCKET.get('path/to/object.png');
// Returns R2Object | null
if (!obj) return new Response('Not found', { status: 404 });
return new Response(obj.body, {
  headers: { 'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream' },
});

### List (paginates - ALWAYS handle the cursor)
const listed = await env.MY_BUCKET.list({ prefix: 'uploads/', limit: 1000 });
let objects = listed.objects;
if (listed.truncated) {
  const next = await env.MY_BUCKET.list({ prefix: 'uploads/', cursor: listed.cursor });
  objects = [...objects, ...next.objects];
}

### Delete
await env.MY_BUCKET.delete('path/to/object.png');

### Head (metadata only, no body)
const head = await env.MY_BUCKET.head('path/to/object.png');
// Returns R2Object | null, body is undefined

## S3-compatible API (external clients)

const client = new S3Client({
  region: 'auto',                                          // REQUIRED - not 'us-east-1'
  endpoint: 'https://ACCOUNT_ID.r2.cloudflarestorage.com',// REQUIRED
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

## Signed URLs (via S3 SDK)
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } from '@aws-sdk/client-s3';

const url = await getSignedUrl(client, new GetObjectCommand({
  Bucket: 'my-production-bucket',
  Key: 'path/to/object.png',
}), { expiresIn: 3600 });

## Environment variables for S3 SDK
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=

## Hard rules
- NEVER use region: 'us-east-1' with R2 - the correct value is 'auto'
- NEVER import @aws-sdk inside a Worker handler that has a binding available
- NEVER call env.MY_BUCKET.put without httpMetadata.contentType for user-facing objects
  (omitting it defaults to application/octet-stream, which browsers refuse to render inline)
- NEVER assume list() returns all objects in one call - always check listed.truncated
- NEVER store R2 API tokens in wrangler.toml - use wrangler secret put or .dev.vars for local
- ALWAYS check if R2Object is null before accessing .body or .httpMetadata
- ALWAYS generate types: run 'wrangler types' after changing wrangler.toml bindings

Three rules here prevent the majority of R2 bugs Claude generates without them.

The binding-over-SDK rule is the most impactful entry. Every time Claude sees an object storage task in a Workers context without this rule, it reaches for @aws-sdk/client-s3 because that is what most object storage TypeScript code looks like in its training data. The result is a Worker that makes an outbound HTTPS call to Cloudflare's S3-compatible endpoint from inside Cloudflare's runtime, adding a network round-trip that the binding eliminates entirely. The rule points Claude at the binding first and reserves the SDK for the cases where it belongs.

The region: 'auto' rule catches the second most common mistake. Claude generates region: 'us-east-1' by default because that is the safe default for AWS S3 and it appears in the vast majority of S3 SDK examples. For R2, that value causes request signing failures or routes requests incorrectly. The 'auto' value is specific to R2 and not something Claude infers from context.

The httpMetadata.contentType rule prevents a class of silent storage bugs. R2 accepts a put without a content type. The object is stored. When it is served later, the browser receives Content-Type: application/octet-stream and treats it as a download rather than displaying it inline. For image uploads, this means broken <img> tags. For HTML pages, the browser renders nothing. For JSON, fetch clients may not parse the response automatically. The rule forces Claude to include httpMetadata on every put where the object will be served to a browser.

wrangler.toml binding setup

The binding declaration in wrangler.toml is what makes env.MY_BUCKET available inside your Worker. The binding value sets the property name on env. The bucket_name is the actual R2 bucket name in your Cloudflare account. These two are independent: your Worker code references binding, your Cloudflare dashboard shows bucket_name.

name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-production-bucket"

[env.development]
[[env.development.r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-dev-bucket"

After changing wrangler.toml bindings, run wrangler types to regenerate worker-configuration.d.ts. This file adds MY_BUCKET: R2Bucket to the Env interface so TypeScript knows the type of env.MY_BUCKET. Without this step, TypeScript will either error or fall back to any. Claude Code does not run wrangler types automatically after editing wrangler.toml, so include a reminder in CLAUDE.md or run it yourself after any binding change.

For local development, wrangler dev uses the [env.development] bindings. If you are using a shared dev bucket and want to run against a local R2 instance instead, wrangler supports local R2 persistence via the --local flag: wrangler dev --local. This stores objects on disk at .wrangler/state/v3/r2/ and requires no network calls during development.

Workers env.MY_BUCKET R2Bucket API

The native binding exposes five methods. Each wraps a single R2 operation with no HTTP overhead.

put

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const key = new URL(request.url).pathname.slice(1);
    const contentType = request.headers.get('Content-Type') ?? 'application/octet-stream';

    await env.MY_BUCKET.put(key, request.body, {
      httpMetadata: {
        contentType,
        cacheControl: 'public, max-age=31536000',
      },
      customMetadata: {
        uploadedBy: request.headers.get('X-User-Id') ?? 'anonymous',
        uploadedAt: new Date().toISOString(),
      },
    });

    return Response.json({ key, status: 'uploaded' }, { status: 201 });
  },
};

The httpMetadata field maps directly to the HTTP headers R2 serves when the object is fetched: contentType becomes Content-Type, cacheControl becomes Cache-Control, contentEncoding becomes Content-Encoding. Set these at upload time. Changing them later requires a re-upload or a copy operation.

customMetadata is a key-value map stored alongside the object. It is returned in the R2Object.customMetadata field on get and head calls. Use it for application-layer metadata that you want to retrieve without reading the object body.

get and serving objects

const obj = await env.MY_BUCKET.get(key);

if (!obj) {
  return new Response('Not found', { status: 404 });
}

// Stream the body directly into the response
return new Response(obj.body, {
  headers: {
    'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream',
    'Cache-Control': obj.httpMetadata?.cacheControl ?? 'public, max-age=3600',
    'ETag': obj.httpEtag,
  },
});

The get return value is R2Object | null. Claude Code sometimes generates code that accesses .body without the null check. In production this throws a TypeError when the key does not exist. The CLAUDE.md rule makes the null guard explicit.

When you need the full object body in memory rather than streamed, use the convenience methods:

const obj = await env.MY_BUCKET.get(key);
if (!obj) return new Response('Not found', { status: 404 });

const text = await obj.text();          // string
const buffer = await obj.arrayBuffer(); // ArrayBuffer
const json = await obj.json();          // parsed JSON

list and cursor pagination

async function listAllObjects(bucket: R2Bucket, prefix: string): Promise<R2Object[]> {
  const objects: R2Object[] = [];
  let cursor: string | undefined;

  do {
    const result = await bucket.list({
      prefix,
      limit: 1000,
      cursor,
    });

    objects.push(...result.objects);
    cursor = result.truncated ? result.cursor : undefined;
  } while (cursor);

  return objects;
}

The list API paginates. When result.truncated is true, result.cursor is a string you pass as cursor in the next call. Claude Code without the CLAUDE.md pagination rule generates a single list() call and iterates result.objects, silently missing any objects beyond the first page. For buckets with more than 1000 objects in a prefix, this produces incomplete results with no error.

The limit maximum is 1000 per call. For very large buckets, the loop above may take many calls. If your Worker has a 30-second CPU time limit, consider paginating lazily rather than fetching all objects at once.

delete

// Single object
await env.MY_BUCKET.delete(key);

// Multiple objects at once (up to 1000 keys per call)
await env.MY_BUCKET.delete(['key1', 'key2', 'key3']);

The batch delete overload accepts an array of up to 1000 keys. Claude Code generates single-key deletes in loops without the CLAUDE.md rule, which is slower and consumes more class-A operations than the batch call.

S3-compatible API for non-Workers clients

When you need to interact with R2 from outside the Workers runtime, the S3-compatible API is the right tool. This covers: Node.js scripts, migration jobs, server-side rendering on other platforms, and any client that already uses @aws-sdk/client-s3.

import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';

const r2 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

// Upload
await r2.send(new PutObjectCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: 'uploads/profile.jpg',
  Body: fileBuffer,
  ContentType: 'image/jpeg',
}));

// Download
const response = await r2.send(new GetObjectCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: 'uploads/profile.jpg',
}));
const body = await response.Body?.transformToByteArray();

// List (also paginates - same cursor discipline applies)
const list = await r2.send(new ListObjectsV2Command({
  Bucket: process.env.R2_BUCKET_NAME,
  Prefix: 'uploads/',
  MaxKeys: 1000,
}));

R2 API tokens are created in the Cloudflare dashboard under R2 > Manage API tokens. They are separate from your account API key. Assign the minimum required permissions: Object Read for read-only scripts, Object Read & Write for upload scripts. Store them in environment variables, never in wrangler.toml or source control.

The region: 'auto' value is not a valid AWS S3 region. It is a Cloudflare-specific value that tells the R2 endpoint to route the request to the bucket's home region automatically. Without this in CLAUDE.md, Claude generates 'us-east-1' and requests either fail authentication or are rejected by the endpoint.

For an AWS S3 migration to R2, the S3-compatible API means most migration scripts work with only two changes: swap the endpoint URL and set region: 'auto'. The @aws-sdk/client-s3 package does not need to change. This is a deliberate design choice from Cloudflare to lower migration friction. For teams already using Claude Code with AWS deployments, the Claude Code with AWS guide covers the S3 native setup and makes the contrast clear.

Multipart uploads

Multipart uploads handle objects larger than the 100 MB single-request limit for the Workers binding, or for resumable uploads via the S3-compatible API.

Via the Workers binding

// Initiate
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', {
  httpMetadata: { contentType: 'application/zip' },
});

// Upload parts (minimum 5 MB each, except the last)
const part1 = await multipart.uploadPart(1, firstChunk);   // returns R2UploadedPart
const part2 = await multipart.uploadPart(2, secondChunk);
const part3 = await multipart.uploadPart(3, lastChunk);    // may be < 5 MB

// Complete
await multipart.complete([part1, part2, part3]);

// Abort if something goes wrong
await multipart.abort();

Via the S3 SDK

import { CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand } from '@aws-sdk/client-s3';

const { UploadId } = await r2.send(new CreateMultipartUploadCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: 'large-file.zip',
  ContentType: 'application/zip',
}));

const parts = [];
for (let i = 0; i < chunks.length; i++) {
  const { ETag } = await r2.send(new UploadPartCommand({
    Bucket: process.env.R2_BUCKET_NAME,
    Key: 'large-file.zip',
    UploadId,
    PartNumber: i + 1,
    Body: chunks[i],
  }));
  parts.push({ PartNumber: i + 1, ETag });
}

await r2.send(new CompleteMultipartUploadCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: 'large-file.zip',
  UploadId,
  MultipartUpload: { Parts: parts },
}));

Add multipart rules to CLAUDE.md if your project handles large uploads:

## Multipart upload rules
- Use multipart for objects > 100 MB in Workers, > 5 GB via S3 SDK
- Minimum part size is 5 MB except for the final part
- ALWAYS abort the multipart upload on error to avoid incomplete upload charges
- ALWAYS store UploadId if spanning multiple requests (it persists until complete or abort)
- Part numbers start at 1, not 0

Claude Code generates part number arrays starting at 0 without the rule. R2 rejects part number 0 with an InvalidPart error that is confusing to debug.

Signed URLs for private objects

Signed URLs let you grant time-limited public access to private objects without exposing your R2 credentials. They are generated using the S3 SDK's presigner package and the R2 S3-compatible endpoint.

import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';

// Download URL (valid for 1 hour)
const downloadUrl = await getSignedUrl(r2, new GetObjectCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: 'private/report.pdf',
}), { expiresIn: 3600 });

// Upload URL (presigned PUT for browser direct upload)
const uploadUrl = await getSignedUrl(r2, new PutObjectCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: `uploads/${userId}/${filename}`,
  ContentType: 'image/jpeg',
}), { expiresIn: 300 }); // 5 minutes

The presigned PUT URL pattern is useful for browser-side uploads that bypass your Worker entirely. The client receives a presigned URL from your API, then PUTs the file directly to R2 from the browser. This avoids streaming large files through your Worker and respects the Workers body size limit.

// Worker endpoint that generates the presigned URL
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { filename, contentType } = await request.json() as {
      filename: string;
      contentType: string;
    };

    const key = `uploads/${crypto.randomUUID()}/${filename}`;

    const uploadUrl = await getSignedUrl(r2, new PutObjectCommand({
      Bucket: env.R2_BUCKET_NAME,
      Key: key,
      ContentType: contentType,
    }), { expiresIn: 300 });

    return Response.json({ uploadUrl, key });
  },
};

Add signed URL rules to CLAUDE.md:

## Signed URL rules
- Signed URLs use the S3 SDK presigner, not the Workers binding
- ALWAYS set a short expiry for upload URLs (300s) and reasonable expiry for download URLs (3600s)
- NEVER log signed URLs - they grant access to private objects
- For browser direct upload: presigned PUT to R2, not through the Worker
- The ContentType in the PutObjectCommand must match what the browser sends in the PUT request

Public buckets and custom domains

R2 buckets are private by default. Two mechanisms make objects publicly accessible: the managed r2.dev subdomain (for development and testing), and a custom domain attached via Cloudflare DNS (for production).

Enabling the r2.dev public URL

In the Cloudflare dashboard, navigate to R2, select your bucket, click Settings, and enable Public Access. This gives you a URL of the form https://pub-{hash}.r2.dev/{key}. The hash is unique to your bucket.

Public access via r2.dev serves objects through Cloudflare's CDN, so it includes caching and edge delivery. However, the r2.dev domain is rate-limited and is not intended for production traffic volume. The Cloudflare docs recommend custom domains for production buckets.

Custom domain setup

  1. Add a CNAME record in Cloudflare DNS pointing files.yourdomain.com to {bucket-name}.{account-id}.r2.cloudflarestorage.com
  2. Set the DNS record to Proxied (orange cloud) in the Cloudflare dashboard
  3. In R2 Settings, add files.yourdomain.com under Custom Domains

After DNS propagation, objects are served at https://files.yourdomain.com/{key}. The Cloudflare proxy handles HTTPS, caching, and DDoS protection.

Add public access rules to CLAUDE.md:

## Public access rules
- r2.dev URL: development and low-traffic use only
- Custom domain: required for production, attach via Cloudflare DNS (Proxied CNAME)
- Public object URLs: https://{custom-domain}/{key} or https://pub-{hash}.r2.dev/{key}
- NEVER expose R2 bucket names or account IDs in client-side code
- Cache-Control headers set at upload time control CDN caching behaviour
- Objects served via custom domain benefit from Cloudflare's global CDN automatically

Claude Code sometimes generates code that constructs the S3 API endpoint URL as the public object URL, which is wrong. The public URL uses the r2.dev subdomain or the custom domain, not the cloudflarestorage.com endpoint. The rule prevents this substitution.

Common R2 gotchas with Claude Code

Four failure modes appear consistently when using Claude Code with R2 without CLAUDE.md constraints.

Wrong API surface inside Workers. Claude generates @aws-sdk/client-s3 import in a Worker handler. The fix is the binding-over-SDK rule. If the project already has @aws-sdk/client-s3 installed for external scripts, Claude may use it inside the Worker handler by pattern-matching the import rather than inferring which context it is in.

region: 'us-east-1' on the S3 client. Claude defaults to 'us-east-1' for every S3 client configuration. For R2 this produces authentication errors. The fix is always region: 'auto' in the S3 SDK config for R2 endpoints.

Missing httpMetadata.contentType on put. Claude omits httpMetadata on env.MY_BUCKET.put() calls because the API does not require it. Objects are stored successfully but served as application/octet-stream. Browsers download the file rather than rendering it. The rule makes content type explicit on every put for objects that will be served.

Single list() call without cursor handling. Claude generates const { objects } = await env.MY_BUCKET.list({ prefix }) and iterates objects. This returns at most 1000 objects. Buckets with more than 1000 objects in a prefix silently return a partial result. The fix is the cursor loop shown in the list section.

For the broader Cloudflare Workers development workflow with Claude Code, including routing, Durable Objects, and the wrangler dev loop, Claude Code with Cloudflare Workers covers the foundational setup that R2 integrations build on. For Vercel-based deployments that need object storage on a different platform, Claude Code with Vercel covers the Vercel Blob and external storage patterns.

R2 cost model: what zero egress means for architecture

R2's billing has three components: storage (GB/month), class-A operations (put, list, multipart), and class-B operations (get, head). Egress bandwidth is zero.

The zero-egress model changes which architecture is optimal. On AWS S3, serving many large files to many users from a public bucket adds up in egress fees fast. On R2, the same pattern costs nothing in bandwidth. This makes R2 the default choice for assets that are read frequently and stored once: images, videos, static exports, build artifacts, and user-uploaded media.

The class-A operation cost (writes) is $4.50 per million. The class-B operation cost (reads) is $0.36 per million. Storage is $0.015 per GB per month. For typical application traffic, the dominant cost at scale is class-B operations from CDN cache misses.

The practical implication for CLAUDE.md: set Cache-Control: public, max-age=31536000 on immutable assets at upload time. This maximises CDN cache hit rate and minimises class-B operation counts on popular objects. For mutable assets, use a shorter max-age and rely on versioned keys rather than cache invalidation.

Add the cost rule to CLAUDE.md:

## R2 cost rules
- ALWAYS set Cache-Control at upload time for objects served via CDN
- Immutable assets (hashed filenames): Cache-Control: public, max-age=31536000, immutable
- Mutable assets: Cache-Control: public, max-age=3600, must-revalidate
- Use versioned keys (content-hash in filename) instead of overwriting the same key for cacheable assets
- Batch deletes (up to 1000 keys) cost one class-A operation, not N operations

Building a reliable R2 integration with Claude Code

The R2 CLAUDE.md in this guide produces code where the Workers binding is used inside Workers handlers for zero-latency, zero-auth object access, the S3-compatible API is reserved for external scripts and signed URL generation, httpMetadata.contentType is always set at upload time, list calls handle cursor pagination, and signed URLs use the correct R2 endpoint configuration.

The underlying principle is the same as any Cloudflare integration with Claude Code. R2 without a CLAUDE.md produces code that reaches for AWS S3 patterns that are technically wrong in the R2 context: incorrect regions, SDK imports inside Worker handlers, and missing content type metadata. The configuration above gives Claude a clear model of the two API surfaces and the rules that distinguish them.

For the mechanics of how CLAUDE.md is read at session start and how to version it alongside your project, see CLAUDE.md explained. Claudify includes an R2-specific CLAUDE.md template with the Workers binding rules, S3 SDK configuration, multipart patterns, and public bucket conventions shown in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame