1---
2title: "Part 5: CI/CD"
3description: Set up GitHub Actions for automated AWS deployments and PR previews — with OIDC credentials provisioned as code.
4sidebar:
5 order: 5
6---
7
8In [Part 4](/aws/tutorial/part-4) you deployed isolated stages by
9hand. Now you'll hand it off to GitHub Actions: pushes to `main`
10deploy to production, pull requests get isolated preview
11environments, and merged PRs clean up after themselves.
12
13The trick to doing this safely is **letting Alchemy provision its own
14CI credentials**. Instead of pasting long-lived AWS access keys into
15GitHub, you'll write a small `stacks/github.ts` that creates a GitHub
16**OIDC provider** and an IAM **role** scoped to your repo — CI
17assumes the role at runtime and no secret ever exists to leak.
18
19## Confirm your state store is remote
20
21CI needs to share state across runs, so a local `.alchemy/` directory
22won't cut it. You already configured `AWS.state()` back in
23[Part 1](/aws/tutorial/part-1#where-state-lives), which stores state
24in an account-regional S3 bucket. Every deploy — local or from CI —
25reads and writes state through that bucket.
26
27Double-check it's still wired up in `alchemy.run.ts`:
28
29```typescript
30// alchemy.run.ts
31export default Alchemy.Stack(
32 "MyApp",
33 {
34 providers: AWS.providers(),
35 state: AWS.state(),
36 },
37 // ...
38);
39```
40
41:::caution
42If you switched to `Alchemy.localState()` at some point, switch back
43to `AWS.state()` (or another shared store) before continuing — GitHub
44Actions runners can't see your local filesystem.
45:::
46
47## Set up an `admin` profile
48
49Your `alchemy.run.ts` only needs enough permission to deploy your
50app. The `stacks/github.ts` you're about to write needs more: it
51creates an IAM `OpenIDConnectProvider` and an IAM `Role`, which
52requires IAM admin rights.
53
54Rather than hand those elevated rights to your day-to-day profile,
55create a dedicated `admin` profile:
56
57```sh
58alchemy profile create admin
59```
60
61Connect AWS to the new profile:
62
63```sh
64alchemy profile edit --profile admin --add AWS
65```
66
67Choose an AWS credential (SSO, access keys, etc.) that's authorized
68for IAM administration.
69
70:::danger[Heads up]
71The credential stored under `--profile admin` can create IAM roles
72with arbitrary permissions. Treat it like root. Do **not** use it for
73everyday `alchemy deploy` runs against your application — keep your
74default profile on a narrowly scoped identity and only reach for
75`admin` when you're touching `stacks/github.ts`.
76:::
77
78You'll also be prompted for a GitHub credential. Pick `gh-cli` if you
79have the GitHub CLI installed, otherwise paste a Personal Access
80Token with the `repo` scope.
81
82## Scaffold the GitHub stack
83
84Create `stacks/github.ts` with an empty stack that loads both
85provider layers and reuses your remote state store. This is a
86one-shot stack you'll deploy from your laptop (under the `admin`
87profile) to provision the IAM role CI will use.
88
89```typescript
90// stacks/github.ts
91import * as Alchemy from "alchemy";
92import * as AWS from "alchemy/AWS";
93import * as GitHub from "alchemy/GitHub";
94import * as Effect from "effect/Effect";
95import * as Layer from "effect/Layer";
96
97export default Alchemy.Stack(
98 "github",
99 {
100 providers: Layer.mergeAll(
101 AWS.providers(),
102 GitHub.providers(),
103 ),
104 state: AWS.state(),
105 },
106 Effect.gen(function* () {
107 // OIDC provider + role go here next
108 }),
109);
110```
111
112Reusing the same `AWS.state()` you set up in Part 1 means the role's
113ARN is tracked alongside the rest of your infrastructure, so any
114future edit (rename, policy change) produces a clean diff.
115
116## Create the OIDC provider
117
118Add an `OpenIDConnectProvider` that tells AWS to trust tokens issued
119by GitHub Actions:
120
121```diff lang="typescript"
122 Effect.gen(function* () {
123+ const oidc = yield* AWS.IAM.OpenIDConnectProvider("GitHubOidc", {
124+ url: "https://token.actions.githubusercontent.com",
125+ clientIDList: ["sts.amazonaws.com"],
126+ // GitHub's well-known OIDC thumbprint. AWS auto-discovers
127+ // thumbprints for github.com these days, but the provider's
128+ // thumbprint sync still requires a non-empty list.
129+ thumbprintList: ["6938fd4d98bab03faadb97b34396831e3780aea1"],
130+ });
131 }),
132```
133
134With this in place, GitHub's token issuer becomes a federated
135identity provider in your AWS account — the raw material for
136short-lived, secretless CI credentials.
137
138## Create the deploy role
139
140Add the IAM role CI will assume. The trust policy restricts who can
141assume it to workflows from **your specific repo**:
142
143```diff lang="typescript"
144 });
145+
146+ const role = yield* AWS.IAM.Role("GitHubActionsRole", {
147+ roleName: "github-actions",
148+ assumeRolePolicyDocument: {
149+ Version: "2012-10-17",
150+ Statement: [
151+ {
152+ Effect: "Allow",
153+ Principal: {
154+ Federated: oidc.openIDConnectProviderArn,
155+ },
156+ Action: ["sts:AssumeRoleWithWebIdentity"],
157+ Condition: {
158+ StringEquals: {
159+ "token.actions.githubusercontent.com:aud":
160+ "sts.amazonaws.com",
161+ },
162+ StringLike: {
163+ "token.actions.githubusercontent.com:sub":
164+ "repo:your-org/your-repo:*",
165+ },
166+ },
167+ },
168+ ],
169+ },
170+ managedPolicyArns: [
171+ "arn:aws:iam::aws:policy/AdministratorAccess",
172+ ],
173+ });
174 }),
175```
176
177Replace `your-org/your-repo` with your GitHub org and repo slug.
178
179:::tip[Tighten the role]
180`AdministratorAccess` is the path of least resistance for getting CI
181working, but for production you should swap it for a custom-managed
182policy that grants only the actions your app needs (for example,
183`lambda:*`, `s3:*` on your buckets, and `iam:` actions for the
184execution roles Alchemy manages). The `assumeRolePolicyDocument`
185already restricts who can assume the role to your specific repo via
186the `sub` condition.
187:::
188
189## Publish the role ARN and region
190
191The workflow needs to know which role to assume and in which region
192to operate. Neither is sensitive, so write them as GitHub Actions
193**variables** (not secrets):
194
195```diff lang="typescript"
196 });
197+
198+ const region = yield* AWS.Region;
199+
200+ yield* GitHub.Variable("aws-role-arn", {
201+ owner: "your-org",
202+ repository: "your-repo",
203+ name: "AWS_ROLE_ARN",
204+ value: role.roleArn,
205+ });
206+
207+ yield* GitHub.Variable("aws-region", {
208+ owner: "your-org",
209+ repository: "your-repo",
210+ name: "AWS_REGION",
211+ value: region,
212+ });
213 }),
214```
215
216`role.roleArn` is an Output — Alchemy resolves it during deploy and
217pipes it straight into the repo variable, so the ARN never needs to
218round-trip through your shell.
219
220## Deploy the GitHub stack
221
222Run it once from your laptop, under the `admin` profile:
223
224```sh
225alchemy deploy --config stacks/github.ts --profile admin
226```
227
228You'll see Alchemy plan the OIDC provider, the role, and the two repo
229variables, then apply them. When it's done, head to your repo's
230**Settings → Secrets and variables → Actions** page; you should see
231`AWS_ROLE_ARN` and `AWS_REGION` under **Variables**.
232
233You only need to re-run this stack when you want to change the role's
234permissions or trust policy.
235
236## Create the workflow
237
238Create `.github/workflows/deploy.yml`:
239
240```yaml
241name: Deploy
242on:
243 push:
244 branches: [main]
245 pull_request:
246 types: [opened, reopened, synchronize, closed]
247
248concurrency:
249 group: deploy-${{ github.ref }}
250 cancel-in-progress: false
251
252env:
253 STAGE: >-
254 ${{ github.event_name == 'pull_request'
255 && format('pr-{0}', github.event.number)
256 || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }}
257
258jobs:
259 deploy:
260 if: github.event.action != 'closed'
261 runs-on: ubuntu-latest
262 permissions:
263 id-token: write
264 contents: read
265 pull-requests: write
266 steps:
267 - uses: actions/checkout@v4
268 - uses: oven-sh/setup-bun@v2
269 - run: bun install
270 - name: Configure AWS credentials
271 uses: aws-actions/configure-aws-credentials@v4
272 with:
273 role-to-assume: ${{ vars.AWS_ROLE_ARN }}
274 aws-region: ${{ vars.AWS_REGION }}
275 - name: Deploy
276 run: bun alchemy deploy --stage ${{ env.STAGE }} --yes
277 env:
278 PULL_REQUEST: ${{ github.event.number }}
279 GITHUB_SHA: ${{ github.sha }}
280 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
281
282 cleanup:
283 if: github.event_name == 'pull_request' && github.event.action == 'closed'
284 runs-on: ubuntu-latest
285 permissions:
286 id-token: write
287 contents: read
288 pull-requests: write
289 steps:
290 - uses: actions/checkout@v4
291 - uses: oven-sh/setup-bun@v2
292 - run: bun install
293 - name: Safety Check
294 run: |
295 if [ "${{ env.STAGE }}" = "prod" ]; then
296 echo "ERROR: Cannot destroy prod environment in cleanup job"
297 exit 1
298 fi
299 - name: Configure AWS credentials
300 uses: aws-actions/configure-aws-credentials@v4
301 with:
302 role-to-assume: ${{ vars.AWS_ROLE_ARN }}
303 aws-region: ${{ vars.AWS_REGION }}
304 - name: Destroy Preview
305 run: bun alchemy destroy --stage ${{ env.STAGE }} --yes
306 env:
307 PULL_REQUEST: ${{ github.event.number }}
308```
309
310The `configure-aws-credentials` step exchanges the workflow's OIDC
311token for short-lived AWS credentials by assuming the role — the
312`id-token: write` permission is what allows the exchange. Alchemy
313picks the credentials up from the environment automatically (when
314`CI=true`, it skips the interactive prompt and uses environment
315variables). No secrets required.
316
317:::note[How STAGE is computed]
318
319- Pull requests → `pr-{number}` (e.g. `pr-42`)
320- Push to `main` → `prod`
321- Other branches → the branch name
322
323The cleanup job includes a safety check so `prod` can never be
324accidentally destroyed — exactly the [stage isolation](/aws/tutorial/part-4)
325from Part 4, driven by CI events.
326:::
327
328## Add PR preview comments
329
330Post the live URL as a comment on every PR. Update `alchemy.run.ts`:
331
332```diff lang="typescript"
333// alchemy.run.ts
334import * as Alchemy from "alchemy";
335import * as AWS from "alchemy/AWS";
336+import * as GitHub from "alchemy/GitHub";
337+import * as Output from "alchemy/Output";
338import * as Effect from "effect/Effect";
339+import * as Layer from "effect/Layer";
340import Api from "./src/api.ts";
341
342export default Alchemy.Stack(
343 "MyApp",
344 {
345- providers: AWS.providers(),
346+ providers: Layer.mergeAll(
347+ AWS.providers(),
348+ GitHub.providers(),
349+ ),
350 state: AWS.state(),
351 },
352 Effect.gen(function* () {
353 const api = yield* Api;
354
355+ if (process.env.PULL_REQUEST) {
356+ yield* GitHub.Comment("preview-comment", {
357+ owner: "your-org",
358+ repository: "your-repo",
359+ issueNumber: Number(process.env.PULL_REQUEST),
360+ body: Output.interpolate`
361+ ## Preview Deployed
362+
363+ **URL:** ${api.functionUrl}
364+
365+ Built from commit ${process.env.GITHUB_SHA?.slice(0, 7)}
366+
367+ ---
368+ _This comment updates automatically with each push._
369+ `,
370+ });
371+ }
372
373 return {
374 url: api.functionUrl,
375 };
376 }),
377);
378```
379
380The logical ID `"preview-comment"` stays the same across pushes, so
381Alchemy updates the existing comment instead of creating a new one.
382`GITHUB_TOKEN` is provided automatically by Actions and authorizes
383the comment.
384
385## The full picture
386
387Here's how everything fits together:
388
3891. You ran `stacks/github.ts` once under `--profile admin`. That
390 created a GitHub OIDC provider and a repo-scoped IAM role, and
391 published the role ARN and region as repo variables.
3922. A developer pushes a branch and opens a PR.
3933. GitHub Actions checks out the code, exchanges its OIDC token for
394 short-lived AWS credentials, and runs
395 `alchemy deploy --stage pr-42`.
3964. Alchemy creates an isolated copy of every resource (bucket,
397 function, etc.) under the `pr-42` stage and posts the preview URL
398 as a PR comment.
3995. The reviewer clicks the URL and tests the preview.
4006. The PR is merged — the cleanup job runs
401 `alchemy destroy --stage pr-42` and the preview infrastructure
402 disappears.
4037. The push to `main` deploys `--stage prod`.
404
405Each environment is fully isolated with its own bucket, function,
406and state.
407
408## Alternative: static access keys
409
410If OIDC isn't an option (some sandbox accounts disallow creating
411identity providers), fall back to IAM access keys stored as repo
412secrets and exposed to the deploy step as `AWS_ACCESS_KEY_ID` /
413`AWS_SECRET_ACCESS_KEY` / `AWS_REGION` environment variables. The
414[CI guide](/environments/ci#aws-with-access-keys) has the complete recipe,
415including a `stacks/github.ts` that pushes the keys into the repo as
416`GitHub.Secret`s.
417
418## Recap
419
420You've completed the tutorial. You now know how to:
421
422- **Part 1** — Create a Stack and deploy a resource
423- **Part 2** — Add a Lambda Function with bindings to other
424 resources
425- **Part 3** — Write integration tests against deployed stacks
426- **Part 4** — Deploy isolated stages with `--stage`
427- **Part 5** — Provision secretless CI credentials with
428 `AWS.IAM.OpenIDConnectProvider` + `AWS.IAM.Role`, publish them
429 with `GitHub.Variable`, and deploy from Actions
430
431## What's next
432
433- Go deeper on AWS — grow this app with
434 [DynamoDB](/aws/data/dynamodb), [SQS](/aws/messaging/sqs),
435 [Kinesis](/aws/messaging/kinesis), and event-driven wiring like
436 [S3 events](/aws/messaging/s3-events) and
437 [DynamoDB Streams](/aws/messaging/dynamodb-streams)
438- Put an [API Gateway REST API](/aws/apis/api-gateway) with
439 stages and custom domains in front of your function
440- Read [Choosing a runtime](/aws/compute/choosing-a-runtime) for when ECS,
441 EKS, or EC2 fits better than Lambda
442- Explore the [CI guide](/environments/ci) for more workflow patterns,
443 including the Cloudflare equivalents
444- Check out the [Testing a Stack](/testing/testing-a-stack) guide for
445 more advanced testing patterns
446- Browse the [Providers](/providers) reference for all available
447 resources