| 1 | --- |
| 2 | title: CI |
| 3 | description: Set up CI/CD pipelines for alchemy projects with GitHub Actions, automated deployments, and PR previews — with provider credentials managed as code. |
| 4 | --- |
| 5 | |
| 6 | import { Code, Steps, TabItem, Tabs } from "@astrojs/starlight/components"; |
| 7 | import yaml from "yaml"; |
| 8 | |
| 9 | export function DeployWorkflow({ manager = "bun" }) { |
| 10 | const managers = { |
| 11 | bun: { |
| 12 | setup: [ |
| 13 | { |
| 14 | name: "Setup Bun", |
| 15 | uses: "oven-sh/setup-bun@v2", |
| 16 | }, |
| 17 | ], |
| 18 | install: "bun install", |
| 19 | run: "bun alchemy", |
| 20 | destroy: "bun alchemy", |
| 21 | }, |
| 22 | npm: { |
| 23 | setup: [ |
| 24 | { |
| 25 | name: "Setup Node.js", |
| 26 | uses: "actions/setup-node@v4", |
| 27 | with: { |
| 28 | "node-version": "24", |
| 29 | }, |
| 30 | }, |
| 31 | ], |
| 32 | install: "npm ci", |
| 33 | run: "npx alchemy", |
| 34 | destroy: "npx alchemy", |
| 35 | }, |
| 36 | pnpm: { |
| 37 | setup: [ |
| 38 | { |
| 39 | name: "Setup pnpm", |
| 40 | uses: "pnpm/action-setup@v4", |
| 41 | with: { |
| 42 | version: "10", |
| 43 | run_install: false, |
| 44 | }, |
| 45 | }, |
| 46 | { |
| 47 | name: "Setup Node.js", |
| 48 | uses: "actions/setup-node@v4", |
| 49 | with: { |
| 50 | "node-version": "24", |
| 51 | cache: "pnpm", |
| 52 | }, |
| 53 | }, |
| 54 | ], |
| 55 | install: "pnpm install", |
| 56 | run: "pnpm dlx alchemy", |
| 57 | destroy: "pnpm dlx alchemy", |
| 58 | }, |
| 59 | yarn: { |
| 60 | setup: [ |
| 61 | { |
| 62 | name: "Setup Node.js", |
| 63 | uses: "actions/setup-node@v4", |
| 64 | with: { |
| 65 | "node-version": "24", |
| 66 | cache: "yarn", |
| 67 | }, |
| 68 | }, |
| 69 | { |
| 70 | name: "Install yarn", |
| 71 | run: "npm install -g yarn", |
| 72 | }, |
| 73 | ], |
| 74 | install: "yarn install", |
| 75 | run: "yarn dlx alchemy", |
| 76 | destroy: "yarn dlx alchemy", |
| 77 | }, |
| 78 | }; |
| 79 | |
| 80 | const cfg = managers[manager] || managers.bun; |
| 81 | |
| 82 | const content = yaml.stringify({ |
| 83 | name: "Deploy Application", |
| 84 | on: { |
| 85 | push: { |
| 86 | branches: ["main"], |
| 87 | }, |
| 88 | pull_request: { |
| 89 | types: ["opened", "reopened", "synchronize", "closed"], |
| 90 | }, |
| 91 | }, |
| 92 | concurrency: { |
| 93 | group: "deploy-${{ github.ref }}", |
| 94 | "cancel-in-progress": false, |
| 95 | }, |
| 96 | env: { |
| 97 | STAGE: |
| 98 | "${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }}", |
| 99 | }, |
| 100 | jobs: { |
| 101 | deploy: { |
| 102 | if: "${{ github.event.action != 'closed' }}", |
| 103 | "runs-on": "ubuntu-latest", |
| 104 | permissions: { |
| 105 | contents: "read", |
| 106 | "pull-requests": "write", |
| 107 | }, |
| 108 | steps: [ |
| 109 | { |
| 110 | uses: "actions/checkout@v4", |
| 111 | }, |
| 112 | ...cfg.setup, |
| 113 | { |
| 114 | name: "Install dependencies", |
| 115 | run: cfg.install, |
| 116 | }, |
| 117 | { |
| 118 | name: "Deploy", |
| 119 | run: `${cfg.run} deploy --stage \${{ env.STAGE }}`, |
| 120 | env: { |
| 121 | PULL_REQUEST: "${{ github.event.number }}", |
| 122 | GITHUB_SHA: "${{ github.sha }}", |
| 123 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}", |
| 124 | }, |
| 125 | }, |
| 126 | ], |
| 127 | }, |
| 128 | cleanup: { |
| 129 | "runs-on": "ubuntu-latest", |
| 130 | if: "${{ github.event_name == 'pull_request' && github.event.action == 'closed' }}", |
| 131 | permissions: { |
| 132 | contents: "read", |
| 133 | "pull-requests": "write", |
| 134 | }, |
| 135 | steps: [ |
| 136 | { |
| 137 | uses: "actions/checkout@v4", |
| 138 | }, |
| 139 | ...cfg.setup, |
| 140 | { |
| 141 | name: "Install dependencies", |
| 142 | run: cfg.install, |
| 143 | }, |
| 144 | { |
| 145 | name: "Safety Check", |
| 146 | run: `if [ "\${{ env.STAGE }}" = "prod" ]; then |
| 147 | echo "ERROR: Cannot destroy prod environment in cleanup job" |
| 148 | exit 1 |
| 149 | fi`, |
| 150 | }, |
| 151 | { |
| 152 | name: "Destroy Preview Environment", |
| 153 | run: `${cfg.destroy} destroy --stage \${{ env.STAGE }}`, |
| 154 | env: { |
| 155 | PULL_REQUEST: "${{ github.event.number }}", |
| 156 | }, |
| 157 | }, |
| 158 | ], |
| 159 | }, |
| 160 | }, |
| 161 | }, { aliasDuplicateObjects: false }); |
| 162 | |
| 163 | return <Code lang="yaml" code={content} />; |
| 164 | } |
| 165 | |
| 166 | The core idea is **credentials as code**. Rather than copy-paste |
| 167 | API keys into the GitHub UI, you let Alchemy provision exactly the |
| 168 | credentials your CI needs — a scoped Cloudflare API token, an AWS |
| 169 | IAM role for OIDC, etc. — and write them straight into the repo as |
| 170 | encrypted secrets. |
| 171 | |
| 172 | ## Preflight: verify the environment |
| 173 | |
| 174 | Every auth provider declares the environment variables its CI resolution |
| 175 | consumes, and `alchemy provider check-env` validates them against that |
| 176 | contract. It prints one line per registered provider and exits with code 1 |
| 177 | when a required variable is missing: |
| 178 | |
| 179 | ```sh |
| 180 | $ bun alchemy provider check-env |
| 181 | ok Cloudflare |
| 182 | MISSING AWS: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION | AWS_DEFAULT_REGION |
| 183 | ``` |
| 184 | |
| 185 | Run it as the first step of a deploy job to fail fast with a list of |
| 186 | missing secrets instead of failing minutes later inside a provider API |
| 187 | call. `--provider <name>` (repeatable) checks a subset; `--env-file` |
| 188 | includes a dotenv file in resolution. |
| 189 | |
| 190 | ## How PR previews work |
| 191 | |
| 192 | Open a PR. Alchemy does the rest. |
| 193 | |
| 194 | 1. **A pull request is opened.** GitHub fires `pull_request`. The |
| 195 | workflow computes `STAGE = pr-{number}` and runs the deploy job. |
| 196 | 2. **Alchemy deploys the stack to that stage.** An isolated copy of |
| 197 | every resource — Workers, Lambdas, queues, tables. |
| 198 | [Stage state lives separately](/environments/stages), so PRs can't |
| 199 | touch each other or prod. |
| 200 | 3. **A bot comment is posted (or updated) on the PR.** The |
| 201 | `GitHub.Comment` resource keeps its logical id stable, so the |
| 202 | comment updates in place on each push. |
| 203 | 4. **PR is merged or closed → cleanup runs.** A second job runs |
| 204 | `alchemy destroy --stage pr-{n}`. Resources gone. Costs gone. |
| 205 | |
| 206 | ## What we'll build |
| 207 | |
| 208 | By the end you'll have: |
| 209 | |
| 210 | 1. A GitHub Actions workflow (`.github/workflows/deploy.yml`) with |
| 211 | PR previews, prod deploys, and automatic cleanup. |
| 212 | 2. A `stacks/github.ts` that creates provider credentials and writes |
| 213 | them to your repo as `GitHub.Secret` / `GitHub.Variable`. |
| 214 | 3. A PR-comment resource in your `alchemy.run.ts` that posts the |
| 215 | preview URL on each push. |
| 216 | |
| 217 | ## GitHub Actions Workflow |
| 218 | |
| 219 | <Steps> |
| 220 | |
| 221 | 1. **Add preview comments to your stack** |
| 222 | |
| 223 | Update your `alchemy.run.ts` to post a preview URL on each pull |
| 224 | request. The comment auto-updates on every push because the |
| 225 | logical ID stays the same. |
| 226 | |
| 227 | ```diff lang="typescript" |
| 228 | import * as Alchemy from "alchemy"; |
| 229 | +import * as GitHub from "alchemy/GitHub"; |
| 230 | +import * as Output from "alchemy/Output"; |
| 231 | import * as Effect from "effect/Effect"; |
| 232 | |
| 233 | export default Alchemy.Stack( |
| 234 | "my-app", |
| 235 | { providers: Layer.mergeAll(GitHub.providers(), Cloudflare.providers()) }, |
| 236 | Effect.gen(function* () { |
| 237 | const app = yield* App; |
| 238 | + const github = yield* GitHub.GitHubEnv; |
| 239 | |
| 240 | + if (github?.pr) { |
| 241 | + yield* GitHub.Comment("preview-comment", { |
| 242 | + owner: github.owner, |
| 243 | + repository: github.repository, |
| 244 | + issueNumber: github.pr, |
| 245 | + body: Output.interpolate` |
| 246 | + ## Preview Deployed |
| 247 | + |
| 248 | + **URL:** ${app.url} |
| 249 | + |
| 250 | + Built from commit ${github.sha.slice(0, 7)} |
| 251 | + |
| 252 | + --- |
| 253 | + _This comment updates automatically with each push._ |
| 254 | + `, |
| 255 | + }); |
| 256 | + } |
| 257 | |
| 258 | return { url: app.url }; |
| 259 | }), |
| 260 | ); |
| 261 | ``` |
| 262 | |
| 263 | 2. **Create the base deployment workflow** |
| 264 | |
| 265 | Create `.github/workflows/deploy.yml`. This workflow deploys `prod` on |
| 266 | pushes to `main` and a `pr-<number>` stage for each pull request. When a |
| 267 | PR is closed the preview environment is destroyed automatically. |
| 268 | |
| 269 | <Tabs syncKey="pkgManager"> |
| 270 | <TabItem label="bun"> |
| 271 | <DeployWorkflow manager="bun" /> |
| 272 | </TabItem> |
| 273 | <TabItem label="npm"> |
| 274 | <DeployWorkflow manager="npm" /> |
| 275 | </TabItem> |
| 276 | <TabItem label="pnpm"> |
| 277 | <DeployWorkflow manager="pnpm" /> |
| 278 | </TabItem> |
| 279 | <TabItem label="yarn"> |
| 280 | <DeployWorkflow manager="yarn" /> |
| 281 | </TabItem> |
| 282 | </Tabs> |
| 283 | |
| 284 | :::note[STAGE Variable Logic] |
| 285 | The `STAGE` environment variable is computed from the GitHub event: |
| 286 | |
| 287 | ```yaml |
| 288 | env: |
| 289 | STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }} |
| 290 | ``` |
| 291 | |
| 292 | - Pull request events → `pr-{number}` |
| 293 | - Pushes to `main` → `prod` |
| 294 | - Other branches → the branch name |
| 295 | |
| 296 | The cleanup job includes a safety check to prevent accidental destruction |
| 297 | of the production environment. |
| 298 | ::: |
| 299 | |
| 300 | 3. **Add provider credentials** |
| 301 | |
| 302 | The base workflow above doesn't include any provider credentials yet. |
| 303 | Pick the section below that matches your provider and apply the changes |
| 304 | to your workflow. |
| 305 | |
| 306 | `GITHUB_TOKEN` is provided automatically by GitHub Actions and is used |
| 307 | by the `GitHub.Comment` resource to post PR comments. |
| 308 | |
| 309 | </Steps> |
| 310 | |
| 311 | ## The GitHub Stack |
| 312 | |
| 313 | Before CI can deploy, your repo needs provider credentials configured |
| 314 | as Actions secrets and variables. We recommend managing this with a |
| 315 | dedicated `stacks/github.ts` that you deploy once locally — that way |
| 316 | the credential set is reviewable, diffable, and reproducible. |
| 317 | |
| 318 | The stack uses `GitHub.Secret` and `GitHub.Variable` to write into |
| 319 | your repository, and provider resources like |
| 320 | `Cloudflare.ApiToken.AccountApiToken` or `AWS.IAM.Role` to mint the |
| 321 | credentials themselves. |
| 322 | |
| 323 | ### Set up an `admin` profile |
| 324 | |
| 325 | The `stacks/github.ts` you're about to write needs more privilege |
| 326 | than your day-to-day app stack. Creating a Cloudflare API token |
| 327 | requires `API Tokens > Write`; creating an IAM role and OIDC |
| 328 | provider requires IAM admin rights. |
| 329 | |
| 330 | Rather than hand those rights to your default |
| 331 | [profile](/environments/profiles), create a separate `admin` profile: |
| 332 | |
| 333 | ```sh |
| 334 | alchemy profile create admin |
| 335 | alchemy profile edit --profile admin |
| 336 | ``` |
| 337 | |
| 338 | :::danger[Heads up] |
| 339 | The credentials stored under `--profile admin` can mint new tokens |
| 340 | and roles with arbitrary scope. Treat it like root. Use it only |
| 341 | when deploying `stacks/github.ts`, and keep your default profile |
| 342 | on a narrowly scoped credential (OAuth, a scoped API token, or |
| 343 | OIDC) for everyday `alchemy deploy` runs. |
| 344 | ::: |
| 345 | |
| 346 | After creating the file, deploy it once locally under the admin |
| 347 | profile: |
| 348 | |
| 349 | ```sh |
| 350 | alchemy deploy --config stacks/github.ts --profile admin |
| 351 | ``` |
| 352 | |
| 353 | You only need to re-run this stack when you want to rotate |
| 354 | credentials or change permissions. |
| 355 | |
| 356 | ## Cloudflare |
| 357 | |
| 358 | The simplest case: a single Cloudflare account, used for both prod |
| 359 | and PR previews. Your `admin` profile mints a scoped API token, and |
| 360 | the stack pushes it into your repo as a GitHub Actions secret. |
| 361 | |
| 362 | :::caution[Required Cloudflare permissions] |
| 363 | The credential behind `--profile admin` must be allowed to create |
| 364 | API tokens. The two practical options are: |
| 365 | |
| 366 | - The **Global API Key** (API Key + Email) — has full account |
| 367 | access, including token management. |
| 368 | - An **API Token** with at least `User > API Tokens > Write` (for |
| 369 | user-owned tokens) **and** `Account > API Tokens > Write` for the |
| 370 | target account. |
| 371 | |
| 372 | A standard "Edit Cloudflare Workers" token is **not** enough — it |
| 373 | can deploy Workers but cannot mint other tokens. Use the Global API |
| 374 | Key for `--profile admin` unless you've explicitly configured a |
| 375 | token-management token. |
| 376 | ::: |
| 377 | |
| 378 | ```typescript |
| 379 | // stacks/github.ts |
| 380 | import * as Alchemy from "alchemy"; |
| 381 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 382 | import * as GitHub from "alchemy/GitHub"; |
| 383 | import * as Effect from "effect/Effect"; |
| 384 | import * as Layer from "effect/Layer"; |
| 385 | import * as Redacted from "effect/Redacted"; |
| 386 | |
| 387 | export default Alchemy.Stack( |
| 388 | "github", |
| 389 | { |
| 390 | providers: Layer.mergeAll( |
| 391 | Cloudflare.providers(), |
| 392 | GitHub.providers(), |
| 393 | ), |
| 394 | state: Cloudflare.state(), |
| 395 | }, |
| 396 | Effect.gen(function* () { |
| 397 | const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; |
| 398 | |
| 399 | const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("CIToken", { |
| 400 | accountId, |
| 401 | policies: [ |
| 402 | { |
| 403 | effect: "allow", |
| 404 | permissionGroups: [ |
| 405 | "Workers Scripts Write", |
| 406 | "Workers KV Storage Write", |
| 407 | "Workers R2 Storage Write", |
| 408 | "D1 Write", |
| 409 | "Queues Write", |
| 410 | "Pages Write", |
| 411 | "Account Settings Write", |
| 412 | "Secrets Store Write", |
| 413 | "Workers Tail Read", |
| 414 | ], |
| 415 | resources: { |
| 416 | [`com.cloudflare.api.account.${accountId}`]: "*", |
| 417 | }, |
| 418 | }, |
| 419 | ], |
| 420 | }); |
| 421 | |
| 422 | yield* GitHub.Secret("cf-api-token", { |
| 423 | owner: "your-org", |
| 424 | repository: "your-repo", |
| 425 | name: "CLOUDFLARE_API_TOKEN", |
| 426 | value: apiToken.value, |
| 427 | }); |
| 428 | |
| 429 | yield* GitHub.Secret("cf-account-id", { |
| 430 | owner: "your-org", |
| 431 | repository: "your-repo", |
| 432 | name: "CLOUDFLARE_ACCOUNT_ID", |
| 433 | value: Redacted.make(accountId), |
| 434 | }); |
| 435 | }), |
| 436 | ); |
| 437 | ``` |
| 438 | |
| 439 | A few details worth knowing: |
| 440 | |
| 441 | - `Cloudflare.CloudflareEnvironment` resolves the credentials of the |
| 442 | profile you're deploying with, account ID included — no |
| 443 | `CLOUDFLARE_ACCOUNT_ID` env var needed for this local deploy. The |
| 444 | double `yield*` is because the service's value is itself an Effect: |
| 445 | credentials are cached and can refresh themselves (OAuth tokens |
| 446 | expire), so you yield once for the service and once to resolve the |
| 447 | current credentials. |
| 448 | - `Cloudflare.ApiToken.AccountApiToken` calls |
| 449 | `POST /accounts/{account_id}/tokens` and Cloudflare returns the |
| 450 | freshly minted token value exactly once. Alchemy captures it, |
| 451 | stores it in state, and pipes it straight into `GitHub.Secret` — |
| 452 | the raw token never appears in your terminal or in CI logs. |
| 453 | - Trim the `permissionGroups` list to just what your app needs. |
| 454 | Listed above is a sensible default for a Workers + KV + R2 + D1 |
| 455 | app. |
| 456 | - The resource diff is stable across redeploys. Editing a policy |
| 457 | produces a clean update against the same token ID. |
| 458 | |
| 459 | :::note[Why `Secrets Store Write`?] |
| 460 | `Cloudflare.state()` keeps the state-store worker's bearer token in |
| 461 | the account-wide [Cloudflare Secrets Store](https://developers.cloudflare.com/secrets-store/). |
| 462 | On every CI run Alchemy reads that secret back by uploading a |
| 463 | short-lived edge-preview worker that *binds* the secret — and |
| 464 | binding a Secrets Store secret to a worker (even an ephemeral |
| 465 | preview) requires the **`Secrets Store Write`** permission, not |
| 466 | just `Read`. `Read` is only enough to enumerate the store via |
| 467 | `GET /accounts/{id}/secrets_store/stores`; the |
| 468 | `POST .../scripts/{name}/edge-preview` call that mounts the |
| 469 | binding is rejected without `Write`. |
| 470 | ::: |
| 471 | |
| 472 | Then add the secrets to your workflow's deploy and cleanup steps: |
| 473 | |
| 474 | ```diff lang="yaml" |
| 475 | - name: Deploy |
| 476 | run: bun alchemy deploy --stage ${{ env.STAGE }} |
| 477 | env: |
| 478 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 479 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 480 | PULL_REQUEST: ${{ github.event.number }} |
| 481 | GITHUB_SHA: ${{ github.sha }} |
| 482 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 483 | ``` |
| 484 | |
| 485 | ```diff lang="yaml" |
| 486 | - name: Destroy Preview Environment |
| 487 | run: bun alchemy destroy --stage ${{ env.STAGE }} |
| 488 | env: |
| 489 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 490 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 491 | PULL_REQUEST: ${{ github.event.number }} |
| 492 | ``` |
| 493 | |
| 494 | ### Optional: separate test and prod accounts |
| 495 | |
| 496 | For stricter isolation you can run preview environments on one |
| 497 | Cloudflare account and production on another. Mint two tokens from |
| 498 | the same `stacks/github.ts` and prefix the secrets so the workflow |
| 499 | can pick the right pair based on `STAGE`. |
| 500 | |
| 501 | Your profile resolves a single account ID, so this is the one case |
| 502 | where you pass the account IDs in explicitly — set |
| 503 | `TEST_CLOUDFLARE_ACCOUNT_ID` and `PROD_CLOUDFLARE_ACCOUNT_ID` in |
| 504 | your environment when deploying this stack. |
| 505 | |
| 506 | ```typescript |
| 507 | // stacks/github.ts |
| 508 | import * as Alchemy from "alchemy"; |
| 509 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 510 | import * as GitHub from "alchemy/GitHub"; |
| 511 | import * as Config from "effect/Config"; |
| 512 | import * as Effect from "effect/Effect"; |
| 513 | import * as Layer from "effect/Layer"; |
| 514 | import * as Redacted from "effect/Redacted"; |
| 515 | |
| 516 | export default Alchemy.Stack( |
| 517 | "github", |
| 518 | { |
| 519 | providers: Layer.mergeAll( |
| 520 | Cloudflare.providers(), |
| 521 | GitHub.providers(), |
| 522 | ), |
| 523 | state: Cloudflare.state(), |
| 524 | }, |
| 525 | Effect.gen(function* () { |
| 526 | const testAccountId = yield* Config.String("TEST_CLOUDFLARE_ACCOUNT_ID"); |
| 527 | const prodAccountId = yield* Config.String("PROD_CLOUDFLARE_ACCOUNT_ID"); |
| 528 | |
| 529 | const policies = (accountId: string) => [ |
| 530 | { |
| 531 | effect: "allow" as const, |
| 532 | permissionGroups: [ |
| 533 | "Workers Scripts Write", |
| 534 | "Workers KV Storage Write", |
| 535 | "Workers R2 Storage Write", |
| 536 | "D1 Write", |
| 537 | "Queues Write", |
| 538 | "Pages Write", |
| 539 | "Account Settings Write", |
| 540 | "Secrets Store Write", |
| 541 | "Workers Tail Read", |
| 542 | ], |
| 543 | resources: { [`com.cloudflare.api.account.${accountId}`]: "*" }, |
| 544 | }, |
| 545 | ]; |
| 546 | |
| 547 | const testToken = yield* Cloudflare.ApiToken.AccountApiToken("TestApiToken", { |
| 548 | accountId: testAccountId, |
| 549 | policies: policies(testAccountId), |
| 550 | }); |
| 551 | |
| 552 | const prodToken = yield* Cloudflare.ApiToken.AccountApiToken("ProdApiToken", { |
| 553 | accountId: prodAccountId, |
| 554 | policies: policies(prodAccountId), |
| 555 | }); |
| 556 | |
| 557 | const secrets: Record<string, Redacted.Redacted<string>> = { |
| 558 | TEST_CLOUDFLARE_API_TOKEN: testToken.value, |
| 559 | TEST_CLOUDFLARE_ACCOUNT_ID: Redacted.make(testAccountId), |
| 560 | PROD_CLOUDFLARE_API_TOKEN: prodToken.value, |
| 561 | PROD_CLOUDFLARE_ACCOUNT_ID: Redacted.make(prodAccountId), |
| 562 | }; |
| 563 | |
| 564 | yield* Effect.all( |
| 565 | Object.entries(secrets).map(([name, value]) => |
| 566 | GitHub.Secret(name, { |
| 567 | owner: "your-org", |
| 568 | repository: "your-repo", |
| 569 | name, |
| 570 | value, |
| 571 | }), |
| 572 | ), |
| 573 | ); |
| 574 | }), |
| 575 | ); |
| 576 | ``` |
| 577 | |
| 578 | Your `admin` profile needs API-token-write permission on **both** |
| 579 | accounts. The simplest setup is to log in with the Global API Key of |
| 580 | a user that's a member of both. |
| 581 | |
| 582 | In your workflow, switch on `STAGE` to choose the right credential |
| 583 | pair: |
| 584 | |
| 585 | ```diff lang="yaml" |
| 586 | - name: Deploy |
| 587 | run: bun alchemy deploy --stage ${{ env.STAGE }} |
| 588 | env: |
| 589 | + CLOUDFLARE_API_TOKEN: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_API_TOKEN || secrets.TEST_CLOUDFLARE_API_TOKEN }} |
| 590 | + CLOUDFLARE_ACCOUNT_ID: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_ACCOUNT_ID || secrets.TEST_CLOUDFLARE_ACCOUNT_ID }} |
| 591 | PULL_REQUEST: ${{ github.event.number }} |
| 592 | GITHUB_SHA: ${{ github.sha }} |
| 593 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 594 | ``` |
| 595 | |
| 596 | ## AWS with GitHub OIDC (recommended) |
| 597 | |
| 598 | GitHub OIDC lets your workflow assume an IAM role without storing |
| 599 | long-lived access keys. The GitHub stack creates the OIDC provider |
| 600 | and an IAM role scoped to your repo, then writes the role ARN and |
| 601 | region to the repo as Actions **variables** (not secrets — they're |
| 602 | not sensitive). |
| 603 | |
| 604 | Your `--profile admin` AWS credentials need IAM admin rights for |
| 605 | this stack: it creates an `OpenIDConnectProvider` and an IAM `Role`. |
| 606 | Run `alchemy profile edit --profile admin --add AWS` and choose a credential |
| 607 | (SSO/OIDC, access keys, etc.) that's authorized for IAM |
| 608 | administration. |
| 609 | |
| 610 | ```typescript |
| 611 | // stacks/github.ts |
| 612 | import * as Alchemy from "alchemy"; |
| 613 | import * as AWS from "alchemy/AWS"; |
| 614 | import * as GitHub from "alchemy/GitHub"; |
| 615 | import * as Effect from "effect/Effect"; |
| 616 | import * as Layer from "effect/Layer"; |
| 617 | |
| 618 | export default Alchemy.Stack( |
| 619 | "github", |
| 620 | { |
| 621 | providers: Layer.mergeAll( |
| 622 | AWS.providers(), |
| 623 | GitHub.providers(), |
| 624 | ), |
| 625 | }, |
| 626 | Effect.gen(function* () { |
| 627 | const oidc = yield* AWS.IAM.OpenIDConnectProvider("GitHubOidc", { |
| 628 | url: "https://token.actions.githubusercontent.com", |
| 629 | clientIDList: ["sts.amazonaws.com"], |
| 630 | // GitHub's well-known OIDC thumbprint. AWS auto-discovers |
| 631 | // thumbprints for github.com these days, but the provider's |
| 632 | // thumbprint sync still requires a non-empty list. |
| 633 | // https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/ |
| 634 | thumbprintList: ["6938fd4d98bab03faadb97b34396831e3780aea1"], |
| 635 | }); |
| 636 | |
| 637 | const role = yield* AWS.IAM.Role("GitHubActionsRole", { |
| 638 | roleName: "github-actions", |
| 639 | assumeRolePolicyDocument: { |
| 640 | Version: "2012-10-17", |
| 641 | Statement: [ |
| 642 | { |
| 643 | Effect: "Allow", |
| 644 | Principal: { |
| 645 | Federated: oidc.openIDConnectProviderArn, |
| 646 | }, |
| 647 | Action: ["sts:AssumeRoleWithWebIdentity"], |
| 648 | Condition: { |
| 649 | StringEquals: { |
| 650 | "token.actions.githubusercontent.com:aud": |
| 651 | "sts.amazonaws.com", |
| 652 | }, |
| 653 | StringLike: { |
| 654 | "token.actions.githubusercontent.com:sub": |
| 655 | "repo:your-org/your-repo:*", |
| 656 | }, |
| 657 | }, |
| 658 | }, |
| 659 | ], |
| 660 | }, |
| 661 | managedPolicyArns: [ |
| 662 | "arn:aws:iam::aws:policy/AdministratorAccess", |
| 663 | ], |
| 664 | }); |
| 665 | |
| 666 | const region = yield* AWS.Region; |
| 667 | |
| 668 | yield* GitHub.Variable("aws-role-arn", { |
| 669 | owner: "your-org", |
| 670 | repository: "your-repo", |
| 671 | name: "AWS_ROLE_ARN", |
| 672 | value: role.roleArn, |
| 673 | }); |
| 674 | |
| 675 | yield* GitHub.Variable("aws-region", { |
| 676 | owner: "your-org", |
| 677 | repository: "your-repo", |
| 678 | name: "AWS_REGION", |
| 679 | value: region, |
| 680 | }); |
| 681 | }), |
| 682 | ); |
| 683 | ``` |
| 684 | |
| 685 | :::tip[Tighten the role] |
| 686 | `AdministratorAccess` is the path of least resistance for getting |
| 687 | CI working, but for production you should swap it for a |
| 688 | custom-managed policy that grants only the actions your app needs |
| 689 | (for example, `lambda:*`, `dynamodb:*`, and the bucket actions |
| 690 | you use). The `assumeRolePolicyDocument` already restricts who can |
| 691 | assume the role to your specific repo via the `sub` condition. |
| 692 | ::: |
| 693 | |
| 694 | After deploying, update the workflow to add `id-token: write` and |
| 695 | the `configure-aws-credentials` step. No secrets needed: |
| 696 | |
| 697 | ```diff lang="yaml" |
| 698 | deploy: |
| 699 | permissions: |
| 700 | + id-token: write |
| 701 | contents: read |
| 702 | pull-requests: write |
| 703 | steps: |
| 704 | - uses: actions/checkout@v4 |
| 705 | # ...setup and install... |
| 706 | + - name: Configure AWS credentials |
| 707 | + uses: aws-actions/configure-aws-credentials@v4 |
| 708 | + with: |
| 709 | + role-to-assume: ${{ vars.AWS_ROLE_ARN }} |
| 710 | + aws-region: ${{ vars.AWS_REGION }} |
| 711 | - name: Deploy |
| 712 | run: bun alchemy deploy --stage ${{ env.STAGE }} |
| 713 | ``` |
| 714 | |
| 715 | ```diff lang="yaml" |
| 716 | cleanup: |
| 717 | permissions: |
| 718 | + id-token: write |
| 719 | contents: read |
| 720 | pull-requests: write |
| 721 | steps: |
| 722 | - uses: actions/checkout@v4 |
| 723 | # ...setup, install, and safety check... |
| 724 | + - name: Configure AWS credentials |
| 725 | + uses: aws-actions/configure-aws-credentials@v4 |
| 726 | + with: |
| 727 | + role-to-assume: ${{ vars.AWS_ROLE_ARN }} |
| 728 | + aws-region: ${{ vars.AWS_REGION }} |
| 729 | - name: Destroy Preview Environment |
| 730 | run: bun alchemy destroy --stage ${{ env.STAGE }} |
| 731 | ``` |
| 732 | |
| 733 | ## AWS with access keys |
| 734 | |
| 735 | If OIDC isn't an option (some sandbox accounts disallow it, or |
| 736 | you're targeting an account you don't fully control), fall back to |
| 737 | static IAM access keys stored as repo secrets. The stack pushes |
| 738 | existing keys from your environment into the repo — it does **not** |
| 739 | mint a new IAM user, since most teams prefer to do that step in the |
| 740 | AWS console. |
| 741 | |
| 742 | ```typescript |
| 743 | // stacks/github.ts |
| 744 | import * as Alchemy from "alchemy"; |
| 745 | import * as GitHub from "alchemy/GitHub"; |
| 746 | import * as Config from "effect/Config"; |
| 747 | import * as Effect from "effect/Effect"; |
| 748 | import * as Redacted from "effect/Redacted"; |
| 749 | |
| 750 | export default Alchemy.Stack( |
| 751 | "github", |
| 752 | { |
| 753 | providers: GitHub.providers(), |
| 754 | }, |
| 755 | Effect.gen(function* () { |
| 756 | const accessKeyId = yield* Config.String("AWS_ACCESS_KEY_ID"); |
| 757 | const secretAccessKey = yield* Config.Redacted("AWS_SECRET_ACCESS_KEY"); |
| 758 | |
| 759 | yield* GitHub.Secret("aws-access-key-id", { |
| 760 | owner: "your-org", |
| 761 | repository: "your-repo", |
| 762 | name: "AWS_ACCESS_KEY_ID", |
| 763 | value: Redacted.make(accessKeyId), |
| 764 | }); |
| 765 | |
| 766 | yield* GitHub.Secret("aws-secret-access-key", { |
| 767 | owner: "your-org", |
| 768 | repository: "your-repo", |
| 769 | name: "AWS_SECRET_ACCESS_KEY", |
| 770 | value: secretAccessKey, |
| 771 | }); |
| 772 | |
| 773 | yield* GitHub.Variable("aws-region", { |
| 774 | owner: "your-org", |
| 775 | repository: "your-repo", |
| 776 | name: "AWS_REGION", |
| 777 | value: "us-east-1", |
| 778 | }); |
| 779 | }), |
| 780 | ); |
| 781 | ``` |
| 782 | |
| 783 | Then add the secrets to your workflow's deploy and cleanup steps: |
| 784 | |
| 785 | ```diff lang="yaml" |
| 786 | - name: Deploy |
| 787 | run: bun alchemy deploy --stage ${{ env.STAGE }} |
| 788 | env: |
| 789 | + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} |
| 790 | + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} |
| 791 | + AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} |
| 792 | PULL_REQUEST: ${{ github.event.number }} |
| 793 | GITHUB_SHA: ${{ github.sha }} |
| 794 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 795 | ``` |
| 796 | |
| 797 | ```diff lang="yaml" |
| 798 | - name: Destroy Preview Environment |
| 799 | run: bun alchemy destroy --stage ${{ env.STAGE }} |
| 800 | env: |
| 801 | + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} |
| 802 | + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} |
| 803 | + AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} |
| 804 | PULL_REQUEST: ${{ github.event.number }} |
| 805 | ``` |
| 806 | |
| 807 | ## Where next |
| 808 | |
| 809 | - [Stages](/environments/stages) — how `pr-42` and `prod` stay isolated. |
| 810 | - [Profiles](/environments/profiles) documents local credentials and the CI environment-variable matrix. |
| 811 | - [Secrets & env on Cloudflare](/cloudflare/security/secrets-env) — wire app secrets into a Worker. |
| 812 | - [Secrets & env on AWS](/aws/security/secrets-env) — the same walk for Lambda. |