| 1 | --- |
| 2 | title: "Part 5: CI/CD" |
| 3 | description: Set up GitHub Actions for automated AWS deployments and PR previews — with OIDC credentials provisioned as code. |
| 4 | sidebar: |
| 5 | order: 5 |
| 6 | --- |
| 7 | |
| 8 | In [Part 4](/aws/tutorial/part-4) you deployed isolated stages by |
| 9 | hand. Now you'll hand it off to GitHub Actions: pushes to `main` |
| 10 | deploy to production, pull requests get isolated preview |
| 11 | environments, and merged PRs clean up after themselves. |
| 12 | |
| 13 | The trick to doing this safely is **letting Alchemy provision its own |
| 14 | CI credentials**. Instead of pasting long-lived AWS access keys into |
| 15 | GitHub, you'll write a small `stacks/github.ts` that creates a GitHub |
| 16 | **OIDC provider** and an IAM **role** scoped to your repo — CI |
| 17 | assumes the role at runtime and no secret ever exists to leak. |
| 18 | |
| 19 | ## Confirm your state store is remote |
| 20 | |
| 21 | CI needs to share state across runs, so a local `.alchemy/` directory |
| 22 | won't cut it. You already configured `AWS.state()` back in |
| 23 | [Part 1](/aws/tutorial/part-1#where-state-lives), which stores state |
| 24 | in an account-regional S3 bucket. Every deploy — local or from CI — |
| 25 | reads and writes state through that bucket. |
| 26 | |
| 27 | Double-check it's still wired up in `alchemy.run.ts`: |
| 28 | |
| 29 | ```typescript |
| 30 | // alchemy.run.ts |
| 31 | export default Alchemy.Stack( |
| 32 | "MyApp", |
| 33 | { |
| 34 | providers: AWS.providers(), |
| 35 | state: AWS.state(), |
| 36 | }, |
| 37 | // ... |
| 38 | ); |
| 39 | ``` |
| 40 | |
| 41 | :::caution |
| 42 | If you switched to `Alchemy.localState()` at some point, switch back |
| 43 | to `AWS.state()` (or another shared store) before continuing — GitHub |
| 44 | Actions runners can't see your local filesystem. |
| 45 | ::: |
| 46 | |
| 47 | ## Set up an `admin` profile |
| 48 | |
| 49 | Your `alchemy.run.ts` only needs enough permission to deploy your |
| 50 | app. The `stacks/github.ts` you're about to write needs more: it |
| 51 | creates an IAM `OpenIDConnectProvider` and an IAM `Role`, which |
| 52 | requires IAM admin rights. |
| 53 | |
| 54 | Rather than hand those elevated rights to your day-to-day profile, |
| 55 | create a dedicated `admin` profile: |
| 56 | |
| 57 | ```sh |
| 58 | alchemy profile create admin |
| 59 | ``` |
| 60 | |
| 61 | Connect AWS to the new profile: |
| 62 | |
| 63 | ```sh |
| 64 | alchemy profile edit --profile admin --add AWS |
| 65 | ``` |
| 66 | |
| 67 | Choose an AWS credential (SSO, access keys, etc.) that's authorized |
| 68 | for IAM administration. |
| 69 | |
| 70 | :::danger[Heads up] |
| 71 | The credential stored under `--profile admin` can create IAM roles |
| 72 | with arbitrary permissions. Treat it like root. Do **not** use it for |
| 73 | everyday `alchemy deploy` runs against your application — keep your |
| 74 | default profile on a narrowly scoped identity and only reach for |
| 75 | `admin` when you're touching `stacks/github.ts`. |
| 76 | ::: |
| 77 | |
| 78 | You'll also be prompted for a GitHub credential. Pick `gh-cli` if you |
| 79 | have the GitHub CLI installed, otherwise paste a Personal Access |
| 80 | Token with the `repo` scope. |
| 81 | |
| 82 | ## Scaffold the GitHub stack |
| 83 | |
| 84 | Create `stacks/github.ts` with an empty stack that loads both |
| 85 | provider layers and reuses your remote state store. This is a |
| 86 | one-shot stack you'll deploy from your laptop (under the `admin` |
| 87 | profile) to provision the IAM role CI will use. |
| 88 | |
| 89 | ```typescript |
| 90 | // stacks/github.ts |
| 91 | import * as Alchemy from "alchemy"; |
| 92 | import * as AWS from "alchemy/AWS"; |
| 93 | import * as GitHub from "alchemy/GitHub"; |
| 94 | import * as Effect from "effect/Effect"; |
| 95 | import * as Layer from "effect/Layer"; |
| 96 | |
| 97 | export 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 | |
| 112 | Reusing the same `AWS.state()` you set up in Part 1 means the role's |
| 113 | ARN is tracked alongside the rest of your infrastructure, so any |
| 114 | future edit (rename, policy change) produces a clean diff. |
| 115 | |
| 116 | ## Create the OIDC provider |
| 117 | |
| 118 | Add an `OpenIDConnectProvider` that tells AWS to trust tokens issued |
| 119 | by 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 | |
| 134 | With this in place, GitHub's token issuer becomes a federated |
| 135 | identity provider in your AWS account — the raw material for |
| 136 | short-lived, secretless CI credentials. |
| 137 | |
| 138 | ## Create the deploy role |
| 139 | |
| 140 | Add the IAM role CI will assume. The trust policy restricts who can |
| 141 | assume 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 | |
| 177 | Replace `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 |
| 181 | working, but for production you should swap it for a custom-managed |
| 182 | policy that grants only the actions your app needs (for example, |
| 183 | `lambda:*`, `s3:*` on your buckets, and `iam:` actions for the |
| 184 | execution roles Alchemy manages). The `assumeRolePolicyDocument` |
| 185 | already restricts who can assume the role to your specific repo via |
| 186 | the `sub` condition. |
| 187 | ::: |
| 188 | |
| 189 | ## Publish the role ARN and region |
| 190 | |
| 191 | The workflow needs to know which role to assume and in which region |
| 192 | to 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 |
| 217 | pipes it straight into the repo variable, so the ARN never needs to |
| 218 | round-trip through your shell. |
| 219 | |
| 220 | ## Deploy the GitHub stack |
| 221 | |
| 222 | Run it once from your laptop, under the `admin` profile: |
| 223 | |
| 224 | ```sh |
| 225 | alchemy deploy --config stacks/github.ts --profile admin |
| 226 | ``` |
| 227 | |
| 228 | You'll see Alchemy plan the OIDC provider, the role, and the two repo |
| 229 | variables, 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 | |
| 233 | You only need to re-run this stack when you want to change the role's |
| 234 | permissions or trust policy. |
| 235 | |
| 236 | ## Create the workflow |
| 237 | |
| 238 | Create `.github/workflows/deploy.yml`: |
| 239 | |
| 240 | ```yaml |
| 241 | name: Deploy |
| 242 | on: |
| 243 | push: |
| 244 | branches: [main] |
| 245 | pull_request: |
| 246 | types: [opened, reopened, synchronize, closed] |
| 247 | |
| 248 | concurrency: |
| 249 | group: deploy-${{ github.ref }} |
| 250 | cancel-in-progress: false |
| 251 | |
| 252 | env: |
| 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 | |
| 258 | jobs: |
| 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 | |
| 310 | The `configure-aws-credentials` step exchanges the workflow's OIDC |
| 311 | token for short-lived AWS credentials by assuming the role — the |
| 312 | `id-token: write` permission is what allows the exchange. Alchemy |
| 313 | picks the credentials up from the environment automatically (when |
| 314 | `CI=true`, it skips the interactive prompt and uses environment |
| 315 | variables). 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 | |
| 323 | The cleanup job includes a safety check so `prod` can never be |
| 324 | accidentally destroyed — exactly the [stage isolation](/aws/tutorial/part-4) |
| 325 | from Part 4, driven by CI events. |
| 326 | ::: |
| 327 | |
| 328 | ## Add PR preview comments |
| 329 | |
| 330 | Post the live URL as a comment on every PR. Update `alchemy.run.ts`: |
| 331 | |
| 332 | ```diff lang="typescript" |
| 333 | // alchemy.run.ts |
| 334 | import * as Alchemy from "alchemy"; |
| 335 | import * as AWS from "alchemy/AWS"; |
| 336 | +import * as GitHub from "alchemy/GitHub"; |
| 337 | +import * as Output from "alchemy/Output"; |
| 338 | import * as Effect from "effect/Effect"; |
| 339 | +import * as Layer from "effect/Layer"; |
| 340 | import Api from "./src/api.ts"; |
| 341 | |
| 342 | export 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 | |
| 380 | The logical ID `"preview-comment"` stays the same across pushes, so |
| 381 | Alchemy updates the existing comment instead of creating a new one. |
| 382 | `GITHUB_TOKEN` is provided automatically by Actions and authorizes |
| 383 | the comment. |
| 384 | |
| 385 | ## The full picture |
| 386 | |
| 387 | Here's how everything fits together: |
| 388 | |
| 389 | 1. 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. |
| 392 | 2. A developer pushes a branch and opens a PR. |
| 393 | 3. GitHub Actions checks out the code, exchanges its OIDC token for |
| 394 | short-lived AWS credentials, and runs |
| 395 | `alchemy deploy --stage pr-42`. |
| 396 | 4. 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. |
| 399 | 5. The reviewer clicks the URL and tests the preview. |
| 400 | 6. The PR is merged — the cleanup job runs |
| 401 | `alchemy destroy --stage pr-42` and the preview infrastructure |
| 402 | disappears. |
| 403 | 7. The push to `main` deploys `--stage prod`. |
| 404 | |
| 405 | Each environment is fully isolated with its own bucket, function, |
| 406 | and state. |
| 407 | |
| 408 | ## Alternative: static access keys |
| 409 | |
| 410 | If OIDC isn't an option (some sandbox accounts disallow creating |
| 411 | identity providers), fall back to IAM access keys stored as repo |
| 412 | secrets 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, |
| 415 | including a `stacks/github.ts` that pushes the keys into the repo as |
| 416 | `GitHub.Secret`s. |
| 417 | |
| 418 | ## Recap |
| 419 | |
| 420 | You'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 |