Skip to content

R2

R2 is Cloudflare’s object storage: buckets of arbitrarily large objects addressed by key, with an S3-compatible API. In alchemy an R2 bucket is a one-line resource, and Workers reach it through typed, access-scoped bindings.

Reach for R2 whenever you need to store files: uploads, build artifacts, media, backups, or document corpora for AI Search.

src/bucket.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Bucket = Cloudflare.R2.Bucket("Bucket");

Yield it inside your Stack:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { Bucket } from "./src/bucket.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const bucket = yield* Bucket;
return { bucketName: bucket.bucketName };
}),
);

No name required — alchemy generates a unique one from the app, stage, and logical ID.

Bind the bucket in the Worker’s init phase and use the typed client in the runtime handlers. This Worker stores objects on PUT /:key and serves them back on GET /:key:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const key = request.url.split("/").pop()!;
if (request.method === "PUT") {
yield* bucket.put(key, request.stream, {
contentLength: Number(request.headers["content-length"] ?? 0),
});
return HttpServerResponse.empty({ status: 201 });
}
const object = yield* bucket.get(key);
if (object === null) {
return HttpServerResponse.text("Not found", { status: 404 });
}
return HttpServerResponse.text(yield* object.text());
}).pipe(
Effect.catchTag("R2Error", (error) =>
Effect.succeed(
HttpServerResponse.text(error.message, { status: 500 }),
),
),
),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);

R2 operations can fail with a typed R2Error — Effect keeps it in the type system until you handle it, here with Effect.catchTag. Tutorial part 2 builds this Worker step by step.

The bucket capability is split by access level so each Worker gets least privilege:

  • Cloudflare.R2.ReadBuckethead, get, list.
  • Cloudflare.R2.WriteBucketput, delete, multipart uploads.
  • Cloudflare.R2.ReadWriteBucket — both.

Each level has two interchangeable implementations you provide as a layer on the Worker:

  • *BucketBinding (ReadBucketBinding, WriteBucketBinding, ReadWriteBucketBinding) — a native R2 binding on the Worker, the default choice.
  • *BucketHttp (ReadBucketHttp, WriteBucketHttp, ReadWriteBucketHttp) — the same client over Cloudflare’s HTTP API with a scoped API token, for when a native binding isn’t available.

Your runtime code depends only on the capability (ReadBucket(Bucket) etc.) — swapping the implementation layer doesn’t touch the handlers.

Buckets are private by default. Set publicAccess: true to serve objects from Cloudflare’s managed r2.dev domain — useful for preview stacks and anywhere a signed S3 URL or a custom domain isn’t an option. The hostname lands on publicDomain:

const bucket = yield* Cloudflare.R2.Bucket("Assets", {
publicAccess: true,
});
// https://pub-….r2.dev/photo.jpg

This endpoint is rate-limited and intended for non-production use. For production traffic, attach a hostname you control with domains.

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, leaving the bucket and its objects intact. For buckets whose contents are disposable, opt into emptying them first:

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

To keep a bucket even when its declaration goes away, mark it retain — alchemy drops it from state and never calls delete:

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

See Resource lifecycle › Removal policy.

Related:

  • Workers — how buckets get read and written.

Reference: