1---
2title: CI
3description: Set up CI/CD pipelines for alchemy projects with GitHub Actions, automated deployments, and PR previews — with provider credentials managed as code.
4---
5
6import { Code, Steps, TabItem, Tabs } from "@astrojs/starlight/components";
7import yaml from "yaml";
8
9export 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
80const cfg = managers[manager] || managers.bun;
81
82const content = yaml.stringify({
83name: "Deploy Application",
84on: {
85push: {
86branches: ["main"],
87},
88pull_request: {
89types: ["opened", "reopened", "synchronize", "closed"],
90},
91},
92concurrency: {
93group: "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},
100jobs: {
101deploy: {
102if: "${{ 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
147echo "ERROR: Cannot destroy prod environment in cleanup job"
148exit 1
149fi`,
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
163return <Code lang="yaml" code={content} />;
164}
165
166The core idea is **credentials as code**. Rather than copy-paste
167API keys into the GitHub UI, you let Alchemy provision exactly the
168credentials your CI needs — a scoped Cloudflare API token, an AWS
169IAM role for OIDC, etc. — and write them straight into the repo as
170encrypted secrets.
171
172## Preflight: verify the environment
173
174Every auth provider declares the environment variables its CI resolution
175consumes, and `alchemy provider check-env` validates them against that
176contract. It prints one line per registered provider and exits with code 1
177when a required variable is missing:
178
179```sh
180$ bun alchemy provider check-env
181ok Cloudflare
182MISSING AWS: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION | AWS_DEFAULT_REGION
183```
184
185Run it as the first step of a deploy job to fail fast with a list of
186missing secrets instead of failing minutes later inside a provider API
187call. `--provider <name>` (repeatable) checks a subset; `--env-file`
188includes a dotenv file in resolution.
189
190## How PR previews work
191
192Open a PR. Alchemy does the rest.
193
1941. **A pull request is opened.** GitHub fires `pull_request`. The
195 workflow computes `STAGE = pr-{number}` and runs the deploy job.
1962. **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.
2003. **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.
2034. **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
208By the end you'll have:
209
2101. A GitHub Actions workflow (`.github/workflows/deploy.yml`) with
211 PR previews, prod deploys, and automatic cleanup.
2122. A `stacks/github.ts` that creates provider credentials and writes
213 them to your repo as `GitHub.Secret` / `GitHub.Variable`.
2143. 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
2211. **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
2632. **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
3003. **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
313Before CI can deploy, your repo needs provider credentials configured
314as Actions secrets and variables. We recommend managing this with a
315dedicated `stacks/github.ts` that you deploy once locally — that way
316the credential set is reviewable, diffable, and reproducible.
317
318The stack uses `GitHub.Secret` and `GitHub.Variable` to write into
319your repository, and provider resources like
320`Cloudflare.ApiToken.AccountApiToken` or `AWS.IAM.Role` to mint the
321credentials themselves.
322
323### Set up an `admin` profile
324
325The `stacks/github.ts` you're about to write needs more privilege
326than your day-to-day app stack. Creating a Cloudflare API token
327requires `API Tokens > Write`; creating an IAM role and OIDC
328provider requires IAM admin rights.
329
330Rather than hand those rights to your default
331[profile](/environments/profiles), create a separate `admin` profile:
332
333```sh
334alchemy profile create admin
335alchemy profile edit --profile admin
336```
337
338:::danger[Heads up]
339The credentials stored under `--profile admin` can mint new tokens
340and roles with arbitrary scope. Treat it like root. Use it only
341when deploying `stacks/github.ts`, and keep your default profile
342on a narrowly scoped credential (OAuth, a scoped API token, or
343OIDC) for everyday `alchemy deploy` runs.
344:::
345
346After creating the file, deploy it once locally under the admin
347profile:
348
349```sh
350alchemy deploy --config stacks/github.ts --profile admin
351```
352
353You only need to re-run this stack when you want to rotate
354credentials or change permissions.
355
356## Cloudflare
357
358The simplest case: a single Cloudflare account, used for both prod
359and PR previews. Your `admin` profile mints a scoped API token, and
360the stack pushes it into your repo as a GitHub Actions secret.
361
362:::caution[Required Cloudflare permissions]
363The credential behind `--profile admin` must be allowed to create
364API 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
372A standard "Edit Cloudflare Workers" token is **not** enough — it
373can deploy Workers but cannot mint other tokens. Use the Global API
374Key for `--profile admin` unless you've explicitly configured a
375token-management token.
376:::
377
378```typescript
379// stacks/github.ts
380import * as Alchemy from "alchemy";
381import * as Cloudflare from "alchemy/Cloudflare";
382import * as GitHub from "alchemy/GitHub";
383import * as Effect from "effect/Effect";
384import * as Layer from "effect/Layer";
385import * as Redacted from "effect/Redacted";
386
387export 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
439A 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
461the account-wide [Cloudflare Secrets Store](https://developers.cloudflare.com/secrets-store/).
462On every CI run Alchemy reads that secret back by uploading a
463short-lived edge-preview worker that *binds* the secret — and
464binding a Secrets Store secret to a worker (even an ephemeral
465preview) requires the **`Secrets Store Write`** permission, not
466just `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
469binding is rejected without `Write`.
470:::
471
472Then 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
496For stricter isolation you can run preview environments on one
497Cloudflare account and production on another. Mint two tokens from
498the same `stacks/github.ts` and prefix the secrets so the workflow
499can pick the right pair based on `STAGE`.
500
501Your profile resolves a single account ID, so this is the one case
502where you pass the account IDs in explicitly — set
503`TEST_CLOUDFLARE_ACCOUNT_ID` and `PROD_CLOUDFLARE_ACCOUNT_ID` in
504your environment when deploying this stack.
505
506```typescript
507// stacks/github.ts
508import * as Alchemy from "alchemy";
509import * as Cloudflare from "alchemy/Cloudflare";
510import * as GitHub from "alchemy/GitHub";
511import * as Config from "effect/Config";
512import * as Effect from "effect/Effect";
513import * as Layer from "effect/Layer";
514import * as Redacted from "effect/Redacted";
515
516export 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
578Your `admin` profile needs API-token-write permission on **both**
579accounts. The simplest setup is to log in with the Global API Key of
580a user that's a member of both.
581
582In your workflow, switch on `STAGE` to choose the right credential
583pair:
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
598GitHub OIDC lets your workflow assume an IAM role without storing
599long-lived access keys. The GitHub stack creates the OIDC provider
600and an IAM role scoped to your repo, then writes the role ARN and
601region to the repo as Actions **variables** (not secrets — they're
602not sensitive).
603
604Your `--profile admin` AWS credentials need IAM admin rights for
605this stack: it creates an `OpenIDConnectProvider` and an IAM `Role`.
606Run `alchemy profile edit --profile admin --add AWS` and choose a credential
607(SSO/OIDC, access keys, etc.) that's authorized for IAM
608administration.
609
610```typescript
611// stacks/github.ts
612import * as Alchemy from "alchemy";
613import * as AWS from "alchemy/AWS";
614import * as GitHub from "alchemy/GitHub";
615import * as Effect from "effect/Effect";
616import * as Layer from "effect/Layer";
617
618export 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
687CI working, but for production you should swap it for a
688custom-managed policy that grants only the actions your app needs
689(for example, `lambda:*`, `dynamodb:*`, and the bucket actions
690you use). The `assumeRolePolicyDocument` already restricts who can
691assume the role to your specific repo via the `sub` condition.
692:::
693
694After deploying, update the workflow to add `id-token: write` and
695the `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
735If OIDC isn't an option (some sandbox accounts disallow it, or
736you're targeting an account you don't fully control), fall back to
737static IAM access keys stored as repo secrets. The stack pushes
738existing keys from your environment into the repo — it does **not**
739mint a new IAM user, since most teams prefer to do that step in the
740AWS console.
741
742```typescript
743// stacks/github.ts
744import * as Alchemy from "alchemy";
745import * as GitHub from "alchemy/GitHub";
746import * as Config from "effect/Config";
747import * as Effect from "effect/Effect";
748import * as Redacted from "effect/Redacted";
749
750export 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
783Then 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.