Skip to content

Cloudflare.R2 reference

Source: src/Cloudflare/R2/Bucket.ts

A Cloudflare R2 object storage bucket with S3-compatible API.

R2 provides zero-egress-fee object storage. Create a bucket as a resource, then bind it to a Worker to read and write objects at runtime.

Basic R2 bucket

const bucket = yield* Cloudflare.R2.Bucket("MyBucket");

Bucket with location hint

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
locationHint: "wnam",
});

Reading and writing objects

const bucket = yield* Cloudflare.R2.ReadWriteBucket(MyBucket);
// Write an object
yield* bucket.put("hello.txt", "Hello, World!");
// Read an object
const object = yield* bucket.get("hello.txt");
if (object) {
const text = yield* object.text();
}

Streaming upload with content length

const bucket = yield* Cloudflare.R2.ReadWriteBucket(MyBucket);
yield* bucket.put("upload.bin", request.stream, {
contentLength: Number(request.headers["content-length"] ?? 0),
});

Attach one or more custom domains to serve bucket objects from a hostname you control. The domain’s zone must already exist in your Cloudflare account; the zone is inferred from the hostname when omitted, or you can pass a Cloudflare.Zone.Zone resource, a zone ID, or any hostname inside the zone via the zone field.

Single custom domain

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
domains: [{ name: "assets.example.com" }],
});

Multiple custom domains

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
domains: [
{ name: "assets.example.com" },
{ name: "static.example.com" },
],
});

Disable a custom domain without removing it

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
domains: [{ name: "assets.example.com", enabled: false }],
});

Custom domain with explicit zone and TLS settings

const zone = yield* Cloudflare.Zone.Zone("ExampleZone", {
name: "example.com",
});
const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
domains: [
{
name: "assets.example.com",
zone,
minTLS: "1.2",
},
],
});

Enable Cloudflare’s managed r2.dev domain so objects are publicly readable without attaching a custom domain. The hostname is reported on publicDomain. This endpoint is rate-limited and intended for non-production use — use domains for production public access.

Enable public access at r2.dev

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
publicAccess: true,
});
// objects are at https://${bucket.publicDomain}/<key>

Disable public access

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
publicAccess: false,
});

Configure lifecycle rules to automatically delete objects, abort incomplete multipart uploads, or transition objects to InfrequentAccess storage. Pass an empty array (or omit) to clear all rules. See the Cloudflare R2 docs for details and limits (max 1000 rules per bucket).

Delete objects 30 days after upload

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
lifecycleRules: [
{
id: "expire-old-objects",
deleteObjectsTransition: {
condition: { type: "Age", maxAge: 60 * 60 * 24 * 30 },
},
},
],
});

Transition to InfrequentAccess after 60 days, delete after 365

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
lifecycleRules: [
{
id: "archive-then-delete",
prefix: "logs/",
storageClassTransitions: [
{
condition: { type: "Age", maxAge: 60 * 60 * 24 * 60 },
storageClass: "InfrequentAccess",
},
],
deleteObjectsTransition: {
condition: { type: "Age", maxAge: 60 * 60 * 24 * 365 },
},
},
],
});

Abort incomplete multipart uploads after 7 days

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
lifecycleRules: [
{
id: "abort-stale-uploads",
abortMultipartUploadsTransition: {
condition: { type: "Age", maxAge: 60 * 60 * 24 * 7 },
},
},
],
});

Configure CORS rules so browsers can make cross-origin requests against the bucket’s public (custom domain / r2.dev) or S3 API endpoints. Pass an empty array (or omit) to remove the CORS configuration. See the Cloudflare R2 docs for details.

Allow cross-origin reads from any origin

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
cors: [
{
allowedMethods: ["GET", "HEAD"],
allowedOrigins: ["*"],
},
],
});

Browser range reads (e.g. PMTiles map tiles)

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
domains: [{ name: "tiles.example.com" }],
cors: [
{
allowedMethods: ["GET", "HEAD"],
allowedOrigins: ["https://map.example.com"],
allowedHeaders: ["range", "if-match"],
exposeHeaders: ["etag", "content-range"],
maxAgeSeconds: 3600,
},
],
});

Allow uploads from a web app

const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
cors: [
{
allowedMethods: ["GET", "PUT", "POST"],
allowedOrigins: ["https://app.example.com"],
allowedHeaders: ["content-type"],
exposeHeaders: ["etag"],
},
],
});

R2 refuses to delete a bucket that still has objects in it, and alchemy does not bypass that refusal: destroying a non-empty bucket fails with BucketNotEmpty and both the bucket and its objects survive. Opt into emptying the bucket first with forceDestroy for buckets whose contents are disposable.

Empty the bucket on destroy

const cache = yield* Cloudflare.R2.Bucket("Cache", {
forceDestroy: true,
});

Keep the bucket even when the stack goes away

import * as RemovalPolicy from "alchemy/RemovalPolicy";
const uploads = yield* Cloudflare.R2.Bucket("Uploads").pipe(
RemovalPolicy.retain(),
);

Source: src/Cloudflare/R2/BucketEventNotification.ts

Event notifications for a Cloudflare R2 bucket, delivered to a Queue.

When objects in the bucket are created, deleted, or copied, R2 publishes an event message to the configured Queue. One configuration exists per (bucket, queue) pair and holds a list of rules; consume the messages with a Queue consumer Worker.

The configuration’s identity is the (bucket, queue) pair — changing either triggers a replacement, while rule changes are applied in place (the provider converges the pair’s configuration to exactly the declared rule set).

BucketEventNotification: Notifying a Queue

Section titled “BucketEventNotification: Notifying a Queue”

Notify on every upload and delete

const bucket = yield* Cloudflare.R2.Bucket("Uploads");
const queue = yield* Cloudflare.Queues.Queue("UploadEvents");
yield* Cloudflare.R2.BucketEventNotification("UploadNotifications", {
bucketName: bucket.bucketName,
queueId: queue.queueId,
rules: [
{
actions: ["PutObject", "CompleteMultipartUpload", "DeleteObject"],
},
],
});

Scope notifications to a key prefix and suffix

yield* Cloudflare.R2.BucketEventNotification("ImageNotifications", {
bucketName: bucket.bucketName,
queueId: queue.queueId,
rules: [
{
actions: ["PutObject"],
prefix: "images/",
suffix: ".png",
description: "new PNG images",
},
],
});
// Rules must cover non-overlapping key ranges — Cloudflare rejects
// overlapping prefixes/suffixes even when the actions are disjoint.
yield* Cloudflare.R2.BucketEventNotification("Notifications", {
bucketName: bucket.bucketName,
queueId: queue.queueId,
rules: [
{ actions: ["PutObject"], prefix: "incoming/" },
{ actions: ["DeleteObject", "LifecycleDeletion"], prefix: "logs/" },
],
});

Source: src/Cloudflare/R2/BucketSippy.ts

Sippy — incremental migration from AWS S3 or Google Cloud Storage into a Cloudflare R2 bucket.

When Sippy is enabled on a bucket, any object requested from R2 that is not yet present is fetched from the configured source bucket, served, and copied into R2 — migrating data on demand without a bulk transfer and without paying double storage during the transition.

One Sippy configuration exists per bucket (it is a singleton sub-resource of the bucket). Destroying the resource disables Sippy; objects already migrated stay in the R2 bucket.

const bucket = yield* Cloudflare.R2.Bucket("Media");
yield* Cloudflare.R2.BucketSippy("MediaMigration", {
bucketName: bucket.bucketName,
source: {
provider: "aws",
bucket: "legacy-media",
region: "us-east-1",
accessKeyId: yield* Config.Redacted("AWS_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("AWS_SECRET_ACCESS_KEY"),
},
destination: {
accessKeyId: yield* Config.Redacted("R2_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("R2_SECRET_ACCESS_KEY"),
},
});

BucketSippy: Migrating from Google Cloud Storage

Section titled “BucketSippy: Migrating from Google Cloud Storage”
yield* Cloudflare.R2.BucketSippy("MediaMigration", {
bucketName: bucket.bucketName,
source: {
provider: "gcs",
bucket: "legacy-media",
clientEmail: "sippy@my-project.iam.gserviceaccount.com",
privateKey: yield* Config.Redacted("GCS_PRIVATE_KEY"),
},
destination: {
accessKeyId: yield* Config.Redacted("R2_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("R2_SECRET_ACCESS_KEY"),
},
});

Source: src/Cloudflare/R2/DataCatalog.ts

Apache Iceberg data catalog attached to a Cloudflare R2 bucket.

R2 Data Catalog exposes an Iceberg REST catalog endpoint backed by an R2 bucket, so engines like Spark, PyIceberg, and DuckDB can create and query Iceberg tables stored in R2. The catalog is a singleton per bucket: this resource enables it, keeps its maintenance configuration in sync, and disables it on destroy (table data in the bucket is never deleted).

const bucket = yield* Cloudflare.R2.Bucket("LakehouseBucket");
const catalog = yield* Cloudflare.R2.R2DataCatalog("Lakehouse", {
bucketName: bucket.bucketName,
});
// Point any Iceberg REST client at the warehouse:
const uri = catalog.catalogUri;
const warehouse = catalog.name;

Configure compaction and snapshot expiration

const catalog = yield* Cloudflare.R2.R2DataCatalog("Lakehouse", {
bucketName: bucket.bucketName,
compaction: { state: "enabled", targetSizeMb: "256" },
snapshotExpiration: {
state: "enabled",
maxSnapshotAge: "3d",
minSnapshotsToKeep: 5,
},
});

Register a maintenance credential

// Maintenance jobs need an API token with R2 read/write on the bucket.
const catalog = yield* Cloudflare.R2.R2DataCatalog("Lakehouse", {
bucketName: bucket.bucketName,
compaction: { state: "enabled" },
token: maintenanceToken, // Redacted<string>
});

Source: src/Cloudflare/R2/PresignGetObject.ts

Mint presigned download (GET) URLs for objects in an R2 Bucket, so a browser or any HTTP client can read an object without credentials or a Worker in the request path.

Presigning is a pure SigV4 computation against R2’s S3-compatible API — no request is made. When deployed, the binding mints a scoped account API token (Workers R2 Storage Read) and derives R2 S3 credentials from it; the URLs point at https://{accountId}.r2.cloudflarestorage.com.

Under alchemy dev, a locally-emulated bucket is served on the Worker’s local S3 endpoint ({worker url}/cdn-cgi/local/r2/s3) and the minted URLs point there — the same code runs unchanged in both modes.

PresignGetObject: Presigning Download URLs

Section titled “PresignGetObject: Presigning Download URLs”

Mint a presigned GET URL

const presignGet = yield* Cloudflare.R2.PresignGetObject(bucket);
const url = yield* presignGet({ key: "reports/2026-09.pdf" });
// hand `url` to a browser — it can GET the object without credentials

Force a download with a short-lived URL

const url = yield* presignGet({
key: "reports/2026-09.pdf",
expiresIn: 60,
contentDisposition: 'attachment; filename="report.pdf"',
});

Provide the implementation on a Worker

export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const presignGet = yield* Cloudflare.R2.PresignGetObject(Files);
return {
fetch: Effect.gen(function* () {
const url = yield* presignGet({ key: "hello.txt" });
return HttpServerResponse.redirect(url);
}),
};
}).pipe(Effect.provide(Cloudflare.R2.PresignGetObjectToken)),
) {}

Source: src/Cloudflare/R2/PresignGetObjectToken.ts Kind: Layer · Provides: Cloudflare.R2.PresignGetObject

Implementation of PresignGetObject that signs URLs with S3 credentials derived from an API token. Signing is local SigV4 — no request is made to R2.

Deployed, it mints a scoped account API token with Workers R2 Storage Read (access key id = token id, secret = SHA-256 of the token value) and signs URLs for {accountId}.r2.cloudflarestorage.com. Under alchemy dev, a locally-emulated bucket is signed with fixed local credentials for the Worker’s local S3 endpoint, and no token is created.

Source: src/Cloudflare/R2/PresignPutObject.ts

Mint presigned upload (PUT) URLs for objects in an R2 Bucket, so a browser can upload directly to R2 without credentials and without routing the body through a Worker.

Presigning is a pure SigV4 computation against R2’s S3-compatible API — no request is made. When deployed, the binding mints a scoped account API token (Workers R2 Storage Write) and derives R2 S3 credentials from it; the URLs point at https://{accountId}.r2.cloudflarestorage.com.

Under alchemy dev, a locally-emulated bucket is served on the Worker’s local S3 endpoint ({worker url}/cdn-cgi/local/r2/s3) and the minted URLs point there — the same code runs unchanged in both modes, and objects uploaded locally are visible to every local binding of the bucket.

Browser uploads to a deployed bucket also need a matching CORS rule on the Bucket (cors prop). The local endpoint always allows cross-origin requests.

Mint a presigned PUT URL

const presignPut = yield* Cloudflare.R2.PresignPutObject(bucket);
const url = yield* presignPut({ key: "uploads/avatar.png" });
// the browser uploads with: fetch(url, { method: "PUT", body: file })

Pin the uploaded Content-Type

const url = yield* presignPut({
key: "uploads/avatar.png",
expiresIn: 300, // valid for 5 minutes
contentType: "image/png", // uploader must send Content-Type: image/png
});

Allow browser uploads from a web app

const Uploads = Cloudflare.R2.Bucket("Uploads", {
cors: [
{
allowedMethods: ["PUT"],
allowedOrigins: ["https://app.example.com"],
allowedHeaders: ["content-type"],
},
],
});
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const presignPut = yield* Cloudflare.R2.PresignPutObject(Uploads);
return {
fetch: Effect.gen(function* () {
const url = yield* presignPut({ key: crypto.randomUUID() });
return yield* HttpServerResponse.json({ url });
}),
};
}).pipe(Effect.provide(Cloudflare.R2.PresignPutObjectToken)),
) {}

Source: src/Cloudflare/R2/PresignPutObjectToken.ts Kind: Layer · Provides: Cloudflare.R2.PresignPutObject

Implementation of PresignPutObject that signs URLs with S3 credentials derived from an API token. Signing is local SigV4 — no request is made to R2.

Deployed, it mints a scoped account API token with Workers R2 Storage Write (access key id = token id, secret = SHA-256 of the token value) and signs URLs for {accountId}.r2.cloudflarestorage.com. Under alchemy dev, a locally-emulated bucket is signed with fixed local credentials for the Worker’s local S3 endpoint, and no token is created.

Source: src/Cloudflare/R2/S3Credentials.ts

S3 API credentials for an R2 bucket that work unchanged in alchemy dev and when deployed — for presigned URLs or any S3 client.

The native R2 binding cannot presign or speak the S3 API: that requires S3 credentials and R2’s S3 endpoint. S3Credentials provides both, resolved per mode:

  • Deployed — Alchemy mints a scoped account API token for the Worker and derives S3 credentials from it (access key id = token id, secret = SHA-256 of the token value). The endpoint is https://{accountId}.r2.cloudflarestorage.com (.eu./.fedramp. for jurisdictional buckets). The value is injected as a secret.
  • alchemy dev — a locally-emulated bucket is served on the Worker’s local S3 endpoint ({worker url}/cdn-cgi/local/r2/s3) with fixed local credentials. No token or cloud call is involved, and objects are shared with the Worker’s native bindings of the bucket.

The Effect-native presign bindings (PresignGetObjectToken, PresignPutObjectToken) are built on it.

const credentials = yield* Cloudflare.R2.S3Credentials(Uploads, {
access: "read-write",
});
// inside a handler:
const { endpoint, bucketName, accessKeyId, secretAccessKey } =
yield* credentials;
alchemy.run.ts
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export const Api = Cloudflare.Worker("Api", {
main: "./src/worker.ts",
env: {
UPLOADS_S3: Cloudflare.R2.S3Credentials(Uploads, { access: "write" }),
},
});
export type ApiEnv = Cloudflare.InferEnv<typeof Api>;
// src/worker.ts
import { AwsClient } from "aws4fetch";
import type * as Cloudflare from "alchemy/Cloudflare";
import type { ApiEnv } from "../alchemy.run.ts";
export default {
async fetch(request: Request, env: ApiEnv) {
// `UPLOADS_S3` is a JSON string (a secret when deployed)
const s3: Cloudflare.R2.S3CredentialsValue = JSON.parse(env.UPLOADS_S3);
const client = new AwsClient({ ...s3, service: "s3" });
const url = new URL(`${s3.endpoint}/${encodeURIComponent(s3.bucketName)}/avatar.png`);
url.searchParams.set("X-Amz-Expires", "900");
const signed = await client.sign(url.toString(), {
method: "PUT",
aws: { signQuery: true },
});
return Response.json({ url: signed.url });
},
};

Source: src/Cloudflare/R2/SuperSlurperJob.ts

Starts a bulk migration from S3, GCS, or R2 into an R2 bucket.

Deployment returns the job identity without waiting for the migration. Completed and aborted jobs remain resources: redeploying does not run them again. Changing source, target, credentials, or overwrite replaces the job. Destroy only cancels this resource’s active job; it never deletes copied objects or Cloudflare’s job history. Transfers already in flight may finish.

Cloudflare exposes no job name, ownership tags, or idempotency key. The persisted job ID is the only ownership record; similar jobs are never adopted. If a create response or the subsequent state write is lost, the job cannot be recovered automatically and retrying may create a second job. Inspect the account’s migration history and cancel that orphan explicitly. Account-wide adoption and cancellation are intentionally not supported. If reading a cached ID fails with Cloudflare’s ambiguous server error, paginated job history is checked for that exact ID before declaring it absent.

const target = yield* Cloudflare.R2.Bucket("Archive");
const job = yield* Cloudflare.R2.SuperSlurperJob("Migration", {
source: {
vendor: "s3",
bucket: "legacy-archive",
region: "us-east-1",
pathPrefix: "photos/",
secret: {
accessKeyId: yield* Config.Redacted("AWS_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("AWS_SECRET_ACCESS_KEY"),
},
},
target: {
vendor: "r2",
bucket: target.bucketName,
secret: {
accessKeyId: yield* Config.Redacted("R2_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("R2_SECRET_ACCESS_KEY"),
},
},
});

Google Cloud Storage source

const source: Cloudflare.R2.SuperSlurperSource = {
vendor: "gcs",
bucket: "legacy-media",
secret: {
clientEmail: yield* Config.Redacted("GCS_CLIENT_EMAIL"),
privateKey: yield* Config.Redacted("GCS_PRIVATE_KEY"),
},
};

R2 source and paused desired state

yield* Cloudflare.R2.SuperSlurperJob("Migration", {
source: {
vendor: "r2",
bucket: "old-media",
secret: {
accessKeyId: yield* Config.Redacted("SOURCE_R2_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("SOURCE_R2_SECRET_ACCESS_KEY"),
},
},
target: {
vendor: "r2",
bucket: "new-media",
secret: {
accessKeyId: yield* Config.Redacted("TARGET_R2_ACCESS_KEY_ID"),
secretAccessKey: yield* Config.Redacted("TARGET_R2_SECRET_ACCESS_KEY"),
},
},
paused: true,
});
const job = yield* Cloudflare.R2.SuperSlurperJob.ref("Migration");
return { accountId: job.accountId, jobId: job.jobId };