Cloudflare.R2 reference
Bucket
Section titled “Bucket”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.
Bucket: Creating a Bucket
Section titled “Bucket: Creating a Bucket”Basic R2 bucket
const bucket = yield* Cloudflare.R2.Bucket("MyBucket");Bucket with location hint
const bucket = yield* Cloudflare.R2.Bucket("MyBucket", { locationHint: "wnam",});Bucket: Binding to a Worker
Section titled “Bucket: Binding to a Worker”Reading and writing objects
const bucket = yield* Cloudflare.R2.ReadWriteBucket(MyBucket);
// Write an objectyield* bucket.put("hello.txt", "Hello, World!");
// Read an objectconst 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),});Bucket: Custom Domains
Section titled “Bucket: Custom Domains”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", }, ],});Bucket: Public Development URL
Section titled “Bucket: Public Development URL”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,});Bucket: Object Lifecycle Rules
Section titled “Bucket: Object Lifecycle Rules”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 }, }, }, ],});Bucket: CORS
Section titled “Bucket: CORS”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"], }, ],});Bucket: Deleting a Bucket
Section titled “Bucket: Deleting a Bucket”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(),);BucketEventNotification
Section titled “BucketEventNotification”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", }, ],});BucketEventNotification: Multiple rules
Section titled “BucketEventNotification: Multiple rules”// 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/" }, ],});BucketSippy
Section titled “BucketSippy”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.
BucketSippy: Migrating from AWS S3
Section titled “BucketSippy: Migrating from AWS S3”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"), },});DataCatalog
Section titled “DataCatalog”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).
DataCatalog: Enabling a catalog
Section titled “DataCatalog: Enabling a catalog”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;DataCatalog: Maintenance
Section titled “DataCatalog: Maintenance”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>});PresignGetObject
Section titled “PresignGetObject”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 credentialsForce 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)),) {}PresignGetObjectToken
Section titled “PresignGetObjectToken”Source:
src/Cloudflare/R2/PresignGetObjectToken.tsKind: 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.
PresignPutObject
Section titled “PresignPutObject”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.
PresignPutObject: Presigning Upload URLs
Section titled “PresignPutObject: Presigning Upload URLs”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)),) {}PresignPutObjectToken
Section titled “PresignPutObjectToken”Source:
src/Cloudflare/R2/PresignPutObjectToken.tsKind: 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.
S3Credentials
Section titled “S3Credentials”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.
S3Credentials: Effect-native Workers
Section titled “S3Credentials: Effect-native Workers”const credentials = yield* Cloudflare.R2.S3Credentials(Uploads, { access: "read-write",});// inside a handler:const { endpoint, bucketName, accessKeyId, secretAccessKey } = yield* credentials;S3Credentials: Async Workers
Section titled “S3Credentials: Async Workers”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.tsimport { 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 }); },};SuperSlurperJob
Section titled “SuperSlurperJob”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.
SuperSlurperJob: Migrating from S3
Section titled “SuperSlurperJob: Migrating from S3”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"), }, },});SuperSlurperJob: Migrating from GCS or R2
Section titled “SuperSlurperJob: Migrating from GCS or R2”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,});SuperSlurperJob: Referencing a Migration
Section titled “SuperSlurperJob: Referencing a Migration”const job = yield* Cloudflare.R2.SuperSlurperJob.ref("Migration");return { accountId: job.accountId, jobId: job.jobId };