Skip to content

R2

R2 stores files, called objects, in Buckets. An object is any sequence of bytes, up to five terabytes, stored under a key you choose. R2 speaks the same API as Amazon S3, so existing S3 tools and libraries work with it, and it charges nothing for data read out of it.

A Bucket is a flat map from keys to objects. Keys can contain slashes, like avatars/sam.png, and listing by a prefix like avatars/ makes them behave like folders, but there are no real directories.

Uploads · R2 Bucketlist({ prefix: "avatars/" })
keysize
avatars/ana.png48 KB
avatars/sam.png51 KB
reports/q1.pdf2 MB
reports/q2.pdf2 MB
Objects sit directly in the Bucket, stored flat by key. A prefix only looks like a folder when you list by it.

A Bucket’s data lives in one region, chosen when it’s created, near the first requests or where you hint. R2 caches reads at the edge, and moving data out is free, so serving large files from it costs only the requests.

src/uploads.ts
import * as Cloudflare from "@alchemy.run/cloudflare";
export const Uploads = Cloudflare.R2.Bucket("Uploads");

Alchemy names it from your app, stage and id. To place it, set locationHint, like "weur" for Western Europe. To keep its data inside a legal boundary, set jurisdiction, like "eu".

Bind the Bucket in a Worker. ReadWriteBucket gives the Worker a client that can do both:

src/worker.ts
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const uploads = yield* Cloudflare.R2.ReadWriteBucket(Uploads);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const key = new URL(request.url).pathname.slice(1);
if (request.method === "PUT") {
yield* uploads.put(key, request.stream, {
contentLength: Number(request.headers["content-length"] ?? 0),
});
return HttpServerResponse.empty({ status: 201 });
}
const object = yield* uploads.get(key);
return object
? HttpServerResponse.text(yield* object.text())
: HttpServerResponse.text("Not found", { status: 404 });
}).pipe(Effect.orDie),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);

put takes a stream, so an upload flows straight into the Bucket without being held in memory. get returns null for a missing key. Every operation can fail with an R2Error.

The client comes in three access levels. ReadBucket can head, get and list. WriteBucket can put, delete and upload in parts. ReadWriteBucket can do all of them. A Worker that only serves files should bind ReadBucket.

Each level has two implementations, picked by the Layer you provide. *BucketBinding uses a native R2 binding on the Worker. *BucketHttp uses Cloudflare’s API, with a token Alchemy creates for just this Bucket’s access, for code that doesn’t run in a Worker. The handlers are the same either way.

Uploads and downloads don’t need to pass through your Worker. A presigned URL lets a browser PUT or GET one object directly, for a limited time, with no credentials of its own:

src/worker.ts
const presignPut = yield* Cloudflare.R2.PresignPutObject(Uploads);
const url = yield* presignPut({ key: `avatars/${userId}.png`, expiresIn: 600 });

See R2 presigned URLs.

A Bucket is private. To serve its objects to anyone, attach a hostname on one of your zones with domains. Requests are served and cached at the edge:

src/assets.ts
export const Assets = Cloudflare.R2.Bucket("Assets", {
domains: [{ name: "assets.example.com" }],
});

For previews, publicAccess: true serves the Bucket from a Cloudflare-managed r2.dev hostname instead, found on publicDomain. That hostname is rate limited, so don’t use it in production.

Lifecycle rules delete objects, or move them to cheaper storage, once they reach an age:

src/uploads.ts
export const Uploads = Cloudflare.R2.Bucket("Uploads", {
lifecycleRules: [
{ id: "expire-tmp", prefix: "tmp/", deleteObjectsTransition: { condition: { type: "Age", maxAge: 86400 } } },
],
});

R2 won’t delete a Bucket that has objects in it, so destroying a full Bucket fails and its data stays safe. For a Bucket whose contents you can lose, set forceDestroy, and destroy empties it first:

src/cache.ts
export const Cache = Cloudflare.R2.Bucket("Cache", { forceDestroy: true });

To keep a Bucket even after you remove its declaration, pipe it through RemovalPolicy.retain(). Alchemy then forgets it instead of deleting it. See Removal policy.