| 1 | --- |
| 2 | title: S3 |
| 3 | description: Add an S3 Bucket to your Stack, bind PutObject and GetObject as runtime capabilities, and let Alchemy mint the IAM policy for you. |
| 4 | --- |
| 5 | |
| 6 | **S3** is AWS's object store — blobs of any size, addressed by |
| 7 | key. In Alchemy it's the `Bucket` resource plus a binding per S3 |
| 8 | operation: `GetObject`, `PutObject`, `DeleteObject`, |
| 9 | `HeadObject`, `CopyObject`, `ListObjectsV2`, and the multipart |
| 10 | upload family. Each binding you call attaches the matching IAM |
| 11 | statement — scoped to that bucket's ARN — to your function's |
| 12 | role. Buckets are also an event source: subscribe a Lambda to |
| 13 | new-object notifications with `S3.consumeBucketEvents` (see the |
| 14 | [S3 events guide](/aws/messaging/s3-events)). |
| 15 | |
| 16 | The walkthrough below builds on the function from |
| 17 | [Lambda](/aws/compute/lambda): it adds a **Bucket** for blob storage and |
| 18 | binds **PutObject** + **GetObject** — Alchemy mints a |
| 19 | least-privilege IAM policy from the bindings you actually use. |
| 20 | |
| 21 | ## Add the bucket |
| 22 | |
| 23 | S3 buckets are canonical Alchemy resources, so you create them |
| 24 | the same way as the Lambda — yield the resource inside an Effect |
| 25 | and the Stack captures it. Open `src/api.ts` and add a bucket in |
| 26 | the constructor: |
| 27 | |
| 28 | ```diff lang="typescript" |
| 29 | // src/api.ts |
| 30 | import * as AWS from "alchemy/AWS"; |
| 31 | +import * as S3 from "alchemy/AWS/S3"; |
| 32 | import { Stack } from "alchemy/Stack"; |
| 33 | import * as Effect from "effect/Effect"; |
| 34 | +import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 35 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 36 | |
| 37 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 38 | "Api", |
| 39 | Stack.useSync((stack) => ({ |
| 40 | main: import.meta.url, |
| 41 | functionUrl: true, |
| 42 | memory: stack.stage === "prod" ? 1024 : 512, |
| 43 | })), |
| 44 | Effect.gen(function* () { |
| 45 | + const bucket = yield* S3.Bucket("Blobs"); |
| 46 | |
| 47 | return { |
| 48 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 49 | }; |
| 50 | }), |
| 51 | ) {} |
| 52 | ``` |
| 53 | |
| 54 | `yield* S3.Bucket("Blobs")` registers the resource with the Stack |
| 55 | under the logical id `Blobs` and returns a typed `Bucket` handle. |
| 56 | Other resources — including this Lambda — can use that handle to |
| 57 | bind operations or grant access. |
| 58 | |
| 59 | ## Bind PutObject and GetObject |
| 60 | |
| 61 | S3 operations like `s3:PutObject` and `s3:GetObject` are exposed |
| 62 | as **bindings**. A binding is two things: |
| 63 | |
| 64 | 1. A typed runtime function you call from your handler. |
| 65 | 2. An IAM policy statement that gets attached to the function |
| 66 | role automatically — scoped to the exact bucket ARN. |
| 67 | |
| 68 | Bind them in the constructor, alongside the bucket: |
| 69 | |
| 70 | ```diff lang="typescript" |
| 71 | Effect.gen(function* () { |
| 72 | const bucket = yield* S3.Bucket("Blobs"); |
| 73 | + const putObject = yield* S3.PutObject(bucket); |
| 74 | + const getObject = yield* S3.GetObject(bucket); |
| 75 | |
| 76 | return { |
| 77 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 78 | }; |
| 79 | }); |
| 80 | ``` |
| 81 | |
| 82 | Both capability calls return a callable Effect: `putObject` |
| 83 | takes a `PutObjectRequest` (the AWS SDK shape, minus the `Bucket` |
| 84 | field Alchemy fills in for you) and `getObject` takes a |
| 85 | `GetObjectRequest`. |
| 86 | |
| 87 | ### One binding, one IAM statement |
| 88 | |
| 89 | Each `S3.PutObject(bucket)`-style capability call is **1:1 with an IAM policy statement** |
| 90 | attached to your function's execution role. The mapping is |
| 91 | deterministic: |
| 92 | |
| 93 | | Code | IAM `Action` | IAM `Resource` | |
| 94 | | --------------------------------- | ---------------- | ------------------------ | |
| 95 | | `S3.PutObject(bucket)` | `s3:PutObject` | `bucket.bucketArn` | |
| 96 | | `S3.GetObject(bucket)` | `s3:GetObject` | `bucket.bucketArn/*` | |
| 97 | | `DynamoDB.PutItem(table)` | `dynamodb:PutItem` | `table.tableArn` | |
| 98 | | `SQS.SendMessage(queue)` | `sqs:SendMessage` | `queue.queueArn` | |
| 99 | |
| 100 | So the two capability calls above produce exactly two statements on |
| 101 | the role: |
| 102 | |
| 103 | ```json |
| 104 | [ |
| 105 | { |
| 106 | "Sid": "AWSS3PutObjectBlobs", |
| 107 | "Effect": "Allow", |
| 108 | "Action": ["s3:PutObject"], |
| 109 | "Resource": "arn:aws:s3:::myapp-prod-blobs/*" |
| 110 | }, |
| 111 | { |
| 112 | "Sid": "AWSS3GetObjectBlobs", |
| 113 | "Effect": "Allow", |
| 114 | "Action": ["s3:GetObject"], |
| 115 | "Resource": "arn:aws:s3:::myapp-prod-blobs/*" |
| 116 | } |
| 117 | ] |
| 118 | ``` |
| 119 | |
| 120 | The role is generated, never `"*"`-scoped, and regenerated from |
| 121 | the call sites on every deploy — delete a capability call and the |
| 122 | matching statement disappears next deploy. **The code is the |
| 123 | policy.** |
| 124 | |
| 125 | ## Write objects with `PUT /:key` |
| 126 | |
| 127 | Replace the static handler with one that takes the URL path as |
| 128 | the object key and writes the request body to the bucket: |
| 129 | |
| 130 | ```diff lang="typescript" |
| 131 | return { |
| 132 | - fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 133 | + fetch: Effect.gen(function* () { |
| 134 | + const request = yield* HttpServerRequest; |
| 135 | + const key = new URL(request.url).pathname.slice(1); |
| 136 | + |
| 137 | + if (request.method === "PUT") { |
| 138 | + const body = yield* request.text; |
| 139 | + yield* putObject({ Key: key, Body: body }); |
| 140 | + return HttpServerResponse.empty({ status: 204 }); |
| 141 | + } |
| 142 | + |
| 143 | + return HttpServerResponse.text("Method not allowed", { status: 405 }); |
| 144 | + }), |
| 145 | }; |
| 146 | ``` |
| 147 | |
| 148 | `putObject({ Key, Body })` is the same shape as |
| 149 | `s3:PutObject` in the AWS SDK, minus the `Bucket` field — Alchemy |
| 150 | fills that in from the bucket you bound, so the call site stays |
| 151 | focused on what's actually variable. |
| 152 | |
| 153 | ## Read objects with `GET /:key` |
| 154 | |
| 155 | Add a `GET` branch that fetches the object and pipes it back to |
| 156 | the client: |
| 157 | |
| 158 | ```diff lang="typescript" |
| 159 | if (request.method === "PUT") { |
| 160 | const body = yield* request.text; |
| 161 | yield* putObject({ Key: key, Body: body }); |
| 162 | return HttpServerResponse.empty({ status: 204 }); |
| 163 | } |
| 164 | |
| 165 | +if (request.method === "GET") { |
| 166 | + const result = yield* getObject({ Key: key }); |
| 167 | + return HttpServerResponse.stream(result.Body!); |
| 168 | +} |
| 169 | |
| 170 | return HttpServerResponse.text("Method not allowed", { status: 405 }); |
| 171 | ``` |
| 172 | |
| 173 | `result.Body` is an Effect `Stream<Uint8Array>`, not a buffered |
| 174 | `Buffer`. `HttpServerResponse.stream` flushes each chunk to the |
| 175 | HTTP response as it arrives, so even a multi-gigabyte object |
| 176 | moves through the function without ever being held in memory. |
| 177 | |
| 178 | ## Handle missing objects |
| 179 | |
| 180 | If the key doesn't exist, the AWS SDK throws `NoSuchKey`. The |
| 181 | binding surfaces that as a typed error tag on the Effect, so |
| 182 | `Effect.catchTag` recovers from it without inspecting strings: |
| 183 | |
| 184 | ```diff lang="typescript" |
| 185 | if (request.method === "GET") { |
| 186 | - const result = yield* getObject({ Key: key }); |
| 187 | + const result = yield* getObject({ Key: key }).pipe( |
| 188 | + Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)), |
| 189 | + ); |
| 190 | + if (!result?.Body) { |
| 191 | + return HttpServerResponse.text("Not found", { status: 404 }); |
| 192 | + } |
| 193 | return HttpServerResponse.stream(result.Body!); |
| 194 | } |
| 195 | ``` |
| 196 | |
| 197 | Every binding's failure channel is enumerated this way: the AWS |
| 198 | SDK error names become Effect tags, so the type-checker tells |
| 199 | you which failures the call site needs to handle. |
| 200 | |
| 201 | ## Convert unhandled errors to 500s |
| 202 | |
| 203 | Any failure you didn't `catchTag` is a programmer error — a |
| 204 | missing IAM grant, a transient AWS outage, a bug. Surface those |
| 205 | as 500s by applying `Effect.orDie` once at the request boundary, |
| 206 | rather than per call: |
| 207 | |
| 208 | ```diff lang="typescript" |
| 209 | fetch: Effect.gen(function* () { |
| 210 | // ... PUT and GET branches ... |
| 211 | return HttpServerResponse.text("Method not allowed", { status: 405 }); |
| 212 | -}), |
| 213 | +}).pipe(Effect.orDie), |
| 214 | ``` |
| 215 | |
| 216 | Applying `orDie` once at the outer layer keeps the inner code |
| 217 | free of repetitive error plumbing — the only errors you handle |
| 218 | explicitly are the ones you actually have a recovery for |
| 219 | (`NoSuchKey`, in this case). |
| 220 | |
| 221 | ## Provide the runtime layers |
| 222 | |
| 223 | Bindings declare a capability; **runtime layers** implement it. |
| 224 | For S3 those are `S3.PutObjectHttp` and `S3.GetObjectHttp` — both |
| 225 | ship in `alchemy/AWS/S3` and depend only on the AWS SDK and the |
| 226 | ambient credentials. Provide them at the bottom of the function: |
| 227 | |
| 228 | ```diff lang="typescript" |
| 229 | // src/api.ts |
| 230 | +import * as Layer from "effect/Layer"; |
| 231 | // ... |
| 232 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 233 | "Api", |
| 234 | /* ... props ... */, |
| 235 | Effect.gen(function* () { |
| 236 | /* ... bindings + fetch ... */ |
| 237 | - }), |
| 238 | + }).pipe( |
| 239 | + Effect.provide(Layer.mergeAll(S3.PutObjectHttp, S3.GetObjectHttp)), |
| 240 | + ), |
| 241 | ) {} |
| 242 | ``` |
| 243 | |
| 244 | `Layer.mergeAll` unions multiple layers into one — no order |
| 245 | required — and `Effect.provide` satisfies the binding requirements |
| 246 | declared by `PutObject` and `GetObject`. |
| 247 | |
| 248 | ## Deploy |
| 249 | |
| 250 | ```sh |
| 251 | bun alchemy deploy |
| 252 | ``` |
| 253 | |
| 254 | Alchemy creates the bucket if it doesn't exist, attaches the |
| 255 | generated IAM policy to your Lambda's execution role, and |
| 256 | redeploys the function bundle. |
| 257 | |
| 258 | ## Verify |
| 259 | |
| 260 | ```typescript |
| 261 | // test/integ.test.ts |
| 262 | import * as AWS from "alchemy/AWS"; |
| 263 | import * as Test from "alchemy/Test/Bun"; |
| 264 | import { expect } from "bun:test"; |
| 265 | import * as Effect from "effect/Effect"; |
| 266 | import * as HttpBody from "effect/http/HttpBody"; |
| 267 | import * as HttpClient from "effect/http/HttpClient"; |
| 268 | import Stack from "../alchemy.run.ts"; |
| 269 | |
| 270 | const { test, beforeAll, deploy } = Test.make({ |
| 271 | providers: AWS.providers(), |
| 272 | state: AWS.state(), |
| 273 | }); |
| 274 | |
| 275 | const stack = beforeAll(deploy(Stack)); |
| 276 | |
| 277 | test( |
| 278 | "S3 round-trip", |
| 279 | Effect.gen(function* () { |
| 280 | const { url } = yield* stack; |
| 281 | |
| 282 | const put = yield* HttpClient.put(`${url}/hello.txt`, { |
| 283 | body: HttpBody.text("world"), |
| 284 | }); |
| 285 | expect(put.status).toBe(204); |
| 286 | |
| 287 | const get = yield* HttpClient.get(`${url}/hello.txt`); |
| 288 | expect(yield* get.text).toBe("world"); |
| 289 | }), |
| 290 | ); |
| 291 | ``` |
| 292 | |
| 293 | ```sh |
| 294 | bun test test/integ.test.ts |
| 295 | ``` |
| 296 | |
| 297 | You now have a Lambda that owns a bucket, with no IAM JSON in |
| 298 | sight. |
| 299 | |
| 300 | ## Where next |
| 301 | |
| 302 | - [React to S3 events](/aws/messaging/s3-events) — notify the same |
| 303 | function whenever a new object lands in the bucket, wired |
| 304 | through the same binding pipeline. |
| 305 | - [DynamoDB](/aws/data/dynamodb) — structured records you can look up |
| 306 | by key, alongside the blobs. |
| 307 | - [`Bucket` reference](/providers/aws/s3/bucket) — every prop |
| 308 | and attribute. |