| 1 | --- |
| 2 | title: R2 |
| 3 | description: Cloudflare R2 is object storage — create a bucket, bind it into a Worker with read/write-scoped bindings, and stream objects in and out. |
| 4 | --- |
| 5 | |
| 6 | R2 is Cloudflare's object storage: buckets of arbitrarily large |
| 7 | objects addressed by key, with an S3-compatible API. In alchemy an |
| 8 | R2 bucket is a one-line resource, and Workers reach it through |
| 9 | typed, access-scoped bindings. |
| 10 | |
| 11 | Reach for R2 whenever you need to store files: uploads, build |
| 12 | artifacts, media, backups, or document corpora for |
| 13 | [AI Search](/cloudflare/ai/ai-search). |
| 14 | |
| 15 | ## Create a bucket |
| 16 | |
| 17 | ```typescript |
| 18 | // src/bucket.ts |
| 19 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 20 | |
| 21 | export const Bucket = Cloudflare.R2.Bucket("Bucket"); |
| 22 | ``` |
| 23 | |
| 24 | Yield it inside your Stack: |
| 25 | |
| 26 | ```typescript |
| 27 | // alchemy.run.ts |
| 28 | import * as Alchemy from "alchemy"; |
| 29 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 30 | import * as Effect from "effect/Effect"; |
| 31 | import { Bucket } from "./src/bucket.ts"; |
| 32 | |
| 33 | export default Alchemy.Stack( |
| 34 | "MyApp", |
| 35 | { |
| 36 | providers: Cloudflare.providers(), |
| 37 | state: Cloudflare.state(), |
| 38 | }, |
| 39 | Effect.gen(function* () { |
| 40 | const bucket = yield* Bucket; |
| 41 | |
| 42 | return { bucketName: bucket.bucketName }; |
| 43 | }), |
| 44 | ); |
| 45 | ``` |
| 46 | |
| 47 | No name required — alchemy generates a unique one from the app, |
| 48 | stage, and logical ID. |
| 49 | |
| 50 | ## Bind it to a Worker |
| 51 | |
| 52 | Bind the bucket in the Worker's Construction phase and use the typed client |
| 53 | in the runtime handlers. This Worker stores objects on `PUT /:key` |
| 54 | and serves them back on `GET /:key`: |
| 55 | |
| 56 | ```typescript |
| 57 | // src/worker.ts |
| 58 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 59 | import * as Effect from "effect/Effect"; |
| 60 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 61 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 62 | import { Bucket } from "./bucket.ts"; |
| 63 | |
| 64 | export default Cloudflare.Worker( |
| 65 | "Worker", |
| 66 | { main: import.meta.url }, |
| 67 | Effect.gen(function* () { |
| 68 | const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); |
| 69 | |
| 70 | return { |
| 71 | fetch: Effect.gen(function* () { |
| 72 | const request = yield* HttpServerRequest; |
| 73 | const key = request.url.split("/").pop()!; |
| 74 | |
| 75 | if (request.method === "PUT") { |
| 76 | yield* bucket.put(key, request.stream, { |
| 77 | contentLength: Number(request.headers["content-length"] ?? 0), |
| 78 | }); |
| 79 | return HttpServerResponse.empty({ status: 201 }); |
| 80 | } |
| 81 | |
| 82 | const object = yield* bucket.get(key); |
| 83 | if (object === null) { |
| 84 | return HttpServerResponse.text("Not found", { status: 404 }); |
| 85 | } |
| 86 | return HttpServerResponse.text(yield* object.text()); |
| 87 | }).pipe( |
| 88 | Effect.catchTag("R2Error", (error) => |
| 89 | Effect.succeed( |
| 90 | HttpServerResponse.text(error.message, { status: 500 }), |
| 91 | ), |
| 92 | ), |
| 93 | ), |
| 94 | }; |
| 95 | }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)), |
| 96 | ); |
| 97 | ``` |
| 98 | |
| 99 | R2 operations can fail with a typed `R2Error` — Effect keeps it in |
| 100 | the type system until you handle it, here with `Effect.catchTag`. |
| 101 | [Tutorial part 2](/cloudflare/tutorial/part-2) builds this Worker |
| 102 | step by step. |
| 103 | |
| 104 | ## Read, Write, ReadWrite |
| 105 | |
| 106 | The bucket capability is split by access level so each Worker gets |
| 107 | least privilege: |
| 108 | |
| 109 | - `Cloudflare.R2.ReadBucket` — `head`, `get`, `list`. |
| 110 | - `Cloudflare.R2.WriteBucket` — `put`, `delete`, multipart uploads. |
| 111 | - `Cloudflare.R2.ReadWriteBucket` — both. |
| 112 | |
| 113 | Each level has two interchangeable implementations you provide as a |
| 114 | layer on the Worker: |
| 115 | |
| 116 | - **`*BucketBinding`** (`ReadBucketBinding`, `WriteBucketBinding`, |
| 117 | `ReadWriteBucketBinding`) — a native R2 binding on the Worker, |
| 118 | the default choice. |
| 119 | - **`*BucketHttp`** (`ReadBucketHttp`, `WriteBucketHttp`, |
| 120 | `ReadWriteBucketHttp`) — the same client over Cloudflare's HTTP |
| 121 | API with a scoped API token, for when a native binding isn't |
| 122 | available. |
| 123 | |
| 124 | Your runtime code depends only on the capability |
| 125 | (`ReadBucket(Bucket)` etc.) — swapping the implementation layer |
| 126 | doesn't touch the handlers. |
| 127 | |
| 128 | ## Presigned URLs |
| 129 | |
| 130 | To let browsers upload or download objects directly, mint presigned |
| 131 | URLs with `Cloudflare.R2.PresignPutObject` / `PresignGetObject`, or |
| 132 | declare `Cloudflare.R2.S3Credentials` on an async Worker. The same |
| 133 | code works under `alchemy dev`, where the local bucket is served on |
| 134 | an S3-compatible endpoint. See |
| 135 | [R2 presigned URLs](/cloudflare/data/r2-presigned-urls). |
| 136 | |
| 137 | ## Public development URL |
| 138 | |
| 139 | Buckets are private by default. Set `publicAccess: true` to serve |
| 140 | objects from Cloudflare's managed `r2.dev` domain — useful for |
| 141 | preview stacks and anywhere a signed S3 URL or a custom domain |
| 142 | isn't an option. The hostname lands on `publicDomain`: |
| 143 | |
| 144 | ```typescript |
| 145 | const bucket = yield* Cloudflare.R2.Bucket("Assets", { |
| 146 | publicAccess: true, |
| 147 | }); |
| 148 | // https://pub-….r2.dev/photo.jpg |
| 149 | ``` |
| 150 | |
| 151 | This endpoint is rate-limited and intended for non-production use. |
| 152 | For production traffic, attach a hostname you control with |
| 153 | `domains`. |
| 154 | |
| 155 | ## Destroying a bucket |
| 156 | |
| 157 | R2 refuses to delete a bucket that still has objects in it, and |
| 158 | alchemy does not bypass that refusal: destroying a non-empty bucket |
| 159 | fails with `BucketNotEmpty`, leaving the bucket and its objects |
| 160 | intact. For buckets whose contents are disposable, opt into emptying |
| 161 | them first: |
| 162 | |
| 163 | ```typescript |
| 164 | const cache = yield* Cloudflare.R2.Bucket("Cache", { |
| 165 | forceDestroy: true, |
| 166 | }); |
| 167 | ``` |
| 168 | |
| 169 | To keep a bucket even when its declaration goes away, mark it |
| 170 | `retain` — alchemy drops it from state and never calls delete: |
| 171 | |
| 172 | ```typescript |
| 173 | import * as RemovalPolicy from "alchemy/RemovalPolicy"; |
| 174 | |
| 175 | const uploads = yield* Cloudflare.R2.Bucket("Uploads").pipe( |
| 176 | RemovalPolicy.retain(), |
| 177 | ); |
| 178 | ``` |
| 179 | |
| 180 | See [Resource lifecycle › Removal policy](/infrastructure-as-code/resource-lifecycle#removal-policy). |
| 181 | |
| 182 | ## Where next |
| 183 | |
| 184 | - [Tutorial part 1](/cloudflare/tutorial/part-1) — deploy your first |
| 185 | bucket. |
| 186 | - [R2 presigned URLs](/cloudflare/data/r2-presigned-urls) — direct |
| 187 | browser uploads and downloads, locally and deployed. |
| 188 | - [Add AI Search (AutoRAG)](/cloudflare/ai/ai-search) — RAG over |
| 189 | documents stored in R2. |
| 190 | |
| 191 | Related: |
| 192 | |
| 193 | - [Workers](/cloudflare/compute/workers) — how buckets get read and written. |
| 194 | |
| 195 | Reference: |
| 196 | |
| 197 | - [Bucket API reference](/providers/cloudflare/r2/bucket) |