1---
2title: S3
3description: 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
7key. In Alchemy it's the `Bucket` resource plus a binding per S3
8operation: `GetObject`, `PutObject`, `DeleteObject`,
9`HeadObject`, `CopyObject`, `ListObjectsV2`, and the multipart
10upload family. Each binding you call attaches the matching IAM
11statement — scoped to that bucket's ARN — to your function's
12role. Buckets are also an event source: subscribe a Lambda to
13new-object notifications with `S3.consumeBucketEvents` (see the
14[S3 events guide](/aws/messaging/s3-events)).
15
16The walkthrough below builds on the function from
17[Lambda](/aws/compute/lambda): it adds a **Bucket** for blob storage and
18binds **PutObject** + **GetObject** — Alchemy mints a
19least-privilege IAM policy from the bindings you actually use.
20
21## Add the bucket
22
23S3 buckets are canonical Alchemy resources, so you create them
24the same way as the Lambda — yield the resource inside an Effect
25and the Stack captures it. Open `src/api.ts` and add a bucket in
26the constructor:
27
28```diff lang="typescript"
29// src/api.ts
30import * as AWS from "alchemy/AWS";
31+import * as S3 from "alchemy/AWS/S3";
32import { Stack } from "alchemy/Stack";
33import * as Effect from "effect/Effect";
34+import { HttpServerRequest } from "effect/http/HttpServerRequest";
35import * as HttpServerResponse from "effect/http/HttpServerResponse";
36
37export 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
55under the logical id `Blobs` and returns a typed `Bucket` handle.
56Other resources — including this Lambda — can use that handle to
57bind operations or grant access.
58
59## Bind PutObject and GetObject
60
61S3 operations like `s3:PutObject` and `s3:GetObject` are exposed
62as **bindings**. A binding is two things:
63
641. A typed runtime function you call from your handler.
652. An IAM policy statement that gets attached to the function
66 role automatically — scoped to the exact bucket ARN.
67
68Bind them in the constructor, alongside the bucket:
69
70```diff lang="typescript"
71Effect.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
82Both capability calls return a callable Effect: `putObject`
83takes a `PutObjectRequest` (the AWS SDK shape, minus the `Bucket`
84field Alchemy fills in for you) and `getObject` takes a
85`GetObjectRequest`.
86
87### One binding, one IAM statement
88
89Each `S3.PutObject(bucket)`-style capability call is **1:1 with an IAM policy statement**
90attached to your function's execution role. The mapping is
91deterministic:
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
100So the two capability calls above produce exactly two statements on
101the 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
120The role is generated, never `"*"`-scoped, and regenerated from
121the call sites on every deploy — delete a capability call and the
122matching statement disappears next deploy. **The code is the
123policy.**
124
125## Write objects with `PUT /:key`
126
127Replace the static handler with one that takes the URL path as
128the object key and writes the request body to the bucket:
129
130```diff lang="typescript"
131return {
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
150fills that in from the bucket you bound, so the call site stays
151focused on what's actually variable.
152
153## Read objects with `GET /:key`
154
155Add a `GET` branch that fetches the object and pipes it back to
156the client:
157
158```diff lang="typescript"
159if (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
170return 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
175HTTP response as it arrives, so even a multi-gigabyte object
176moves through the function without ever being held in memory.
177
178## Handle missing objects
179
180If the key doesn't exist, the AWS SDK throws `NoSuchKey`. The
181binding 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"
185if (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
197Every binding's failure channel is enumerated this way: the AWS
198SDK error names become Effect tags, so the type-checker tells
199you which failures the call site needs to handle.
200
201## Convert unhandled errors to 500s
202
203Any failure you didn't `catchTag` is a programmer error — a
204missing IAM grant, a transient AWS outage, a bug. Surface those
205as 500s by applying `Effect.orDie` once at the request boundary,
206rather than per call:
207
208```diff lang="typescript"
209fetch: Effect.gen(function* () {
210 // ... PUT and GET branches ...
211 return HttpServerResponse.text("Method not allowed", { status: 405 });
212-}),
213+}).pipe(Effect.orDie),
214```
215
216Applying `orDie` once at the outer layer keeps the inner code
217free of repetitive error plumbing — the only errors you handle
218explicitly are the ones you actually have a recovery for
219(`NoSuchKey`, in this case).
220
221## Provide the runtime layers
222
223Bindings declare a capability; **runtime layers** implement it.
224For S3 those are `S3.PutObjectHttp` and `S3.GetObjectHttp` — both
225ship in `alchemy/AWS/S3` and depend only on the AWS SDK and the
226ambient 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// ...
232export 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
245required — and `Effect.provide` satisfies the binding requirements
246declared by `PutObject` and `GetObject`.
247
248## Deploy
249
250```sh
251bun alchemy deploy
252```
253
254Alchemy creates the bucket if it doesn't exist, attaches the
255generated IAM policy to your Lambda's execution role, and
256redeploys the function bundle.
257
258## Verify
259
260```typescript
261// test/integ.test.ts
262import * as AWS from "alchemy/AWS";
263import * as Test from "alchemy/Test/Bun";
264import { expect } from "bun:test";
265import * as Effect from "effect/Effect";
266import * as HttpBody from "effect/http/HttpBody";
267import * as HttpClient from "effect/http/HttpClient";
268import Stack from "../alchemy.run.ts";
269
270const { test, beforeAll, deploy } = Test.make({
271 providers: AWS.providers(),
272 state: AWS.state(),
273});
274
275const stack = beforeAll(deploy(Stack));
276
277test(
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
294bun test test/integ.test.ts
295```
296
297You now have a Lambda that owns a bucket, with no IAM JSON in
298sight.
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.