Continuous Integration
This guide walks you through wiring up GitHub Actions for an Alchemy
project: a deploy workflow with PR previews, a stacks/github.ts
that mints scoped CI credentials and pushes them into your repo, and
provider-specific recipes for Cloudflare and AWS.
The unifying idea is credentials as code. Rather than copy-paste API keys into the GitHub UI, you let Alchemy provision exactly the credentials your CI needs — a scoped Cloudflare API token, an AWS IAM role for OIDC, etc. — and write them straight into the repo as encrypted secrets.
How PR previews work
Section titled “How PR previews work”Open a PR. Alchemy does the rest.
- A pull request is opened. GitHub fires
pull_request. The workflow computesSTAGE = pr-{number}and runs the deploy job. - Alchemy deploys the stack to that stage. An isolated copy of every resource — Workers, Lambdas, queues, tables. Stage state lives separately, so PRs can’t touch each other or prod.
- A bot comment is posted (or updated) on the PR. The
GitHub.Commentresource keeps its logical id stable, so the comment updates in place on each push. - PR is merged or closed → cleanup runs. A second job runs
alchemy destroy --stage pr-{n}. Resources gone. Costs gone.
What we’ll build
Section titled “What we’ll build”By the end you’ll have:
- A GitHub Actions workflow (
.github/workflows/deploy.yml) with PR previews, prod deploys, and automatic cleanup. - A
stacks/github.tsthat creates provider credentials and writes them to your repo asGitHub.Secret/GitHub.Variable. - A PR-comment resource in your
alchemy.run.tsthat posts the preview URL on each push.
GitHub Actions Workflow
Section titled “GitHub Actions Workflow”-
Add preview comments to your stack
Update your
alchemy.run.tsto post a preview URL on each pull request. The comment auto-updates on every push because the logical ID stays the same.import * asAlchemy from "alchemy";import Alchemyimport * asGitHub from "alchemy/GitHub";import GitHubimport * asOutput from "alchemy/Output";import Outputimport * asEffect from "effect/Effect";import Effectexport defaultAlchemy.import AlchemyStack(Stack<{url: any;}, unknown>(stackName: string, options: Alchemy.StackProps<unknown>, eff: Effect.Effect<{url: any;}, never, unknown>): Effect.Effect<Alchemy.CompiledStack<{url: any;}, any>, never, never> (+2 overloads)export Stack"my-app",{providers: /* ... */ },StackProps<unknown>.providers: Layer<unknown, never, Alchemy.StackServices>Effect.import Effectgen(function* () {const gen: <any, {url: any;}>(f: () => Generator<any, {url: any;}, never>) => Effect.Effect<{url: any;}, unknown, unknown> (+1 overload)Provides a way to write effectful code using generator functions, simplifying control flow and error handling.
When to Use
genallows you to write code that looks and behaves like synchronous code, but it can handle asynchronous tasks, errors, and complex control flow (like loops and conditions). It helps make asynchronous code more readable and easier to manage.The generator functions work similarly to
async/awaitbut with more explicit control over the execution of effects. You canyield*values from effects and return the final result at the end.constapp = yield*const app: anyApp;anyif (process.var process: NodeJS.Processenv.NodeJS.Process.env: NodeJS.ProcessEnvThe
process.envproperty returns an object containing the user environment. Seeenviron(7).An example of this object looks like:
{TERM: 'xterm-256color',SHELL: '/usr/local/bin/bash',USER: 'maciej',PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',PWD: '/Users/maciej',EDITOR: 'vim',SHLVL: '1',HOME: '/Users/maciej',LOGNAME: 'maciej',_: '/usr/local/bin/node'}It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other
Workerthreads. In other words, the following example would not work:Terminal window node -e 'process.env.foo = "bar"' && echo $fooWhile the following will:
import { env } from 'node:process';env.foo = 'bar';console.log(env.foo);Assigning a property on
process.envwill implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.import { env } from 'node:process';env.test = null;console.log(env.test);// => 'null'env.test = undefined;console.log(env.test);// => 'undefined'Use
deleteto delete a property fromprocess.env.import { env } from 'node:process';env.TEST = 1;delete env.TEST;console.log(env.TEST);// => undefinedOn Windows operating systems, environment variables are case-insensitive.
import { env } from 'node:process';env.TEST = 1;console.log(env.test);// => 1Unless explicitly specified when creating a
Workerinstance, eachWorkerthread has its own copy ofprocess.env, based on its parent thread'sprocess.env, or whatever was specified as theenvoption to theWorkerconstructor. Changes toprocess.envwill not be visible acrossWorkerthreads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy ofprocess.envon aWorkerinstance operates in a case-sensitive manner unlike the main thread.PULL_REQUEST) {string | undefinedyield*GitHub.import GitHubComment("preview-comment", {const Comment: (id: string, props: {owner: Alchemy.Input<string>;repository: Alchemy.Input<string>;issueNumber: Alchemy.Input<number>;body: Alchemy.Input<string>;allowDelete?: Alchemy.Input<boolean | undefined>;token?: Alchemy.Input<string | undefined>;}) => Effect.Effect<GitHub.Comment, never, GitHub.Providers> (+2 overloads)owner: "your-org",owner: Alchemy.Input<string>Repository owner (user or organization).
repository: "your-repo",repository: Alchemy.Input<string>Repository name.
issueNumber:issueNumber: Alchemy.Input<number>Issue or Pull Request number to comment on.
Number(var Number: NumberConstructor(value?: any) => numberAn object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.
process.var process: NodeJS.Processenv.NodeJS.Process.env: NodeJS.ProcessEnvThe
process.envproperty returns an object containing the user environment. Seeenviron(7).An example of this object looks like:
{TERM: 'xterm-256color',SHELL: '/usr/local/bin/bash',USER: 'maciej',PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',PWD: '/Users/maciej',EDITOR: 'vim',SHLVL: '1',HOME: '/Users/maciej',LOGNAME: 'maciej',_: '/usr/local/bin/node'}It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other
Workerthreads. In other words, the following example would not work:Terminal window node -e 'process.env.foo = "bar"' && echo $fooWhile the following will:
import { env } from 'node:process';env.foo = 'bar';console.log(env.foo);Assigning a property on
process.envwill implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.import { env } from 'node:process';env.test = null;console.log(env.test);// => 'null'env.test = undefined;console.log(env.test);// => 'undefined'Use
deleteto delete a property fromprocess.env.import { env } from 'node:process';env.TEST = 1;delete env.TEST;console.log(env.TEST);// => undefinedOn Windows operating systems, environment variables are case-insensitive.
import { env } from 'node:process';env.TEST = 1;console.log(env.test);// => 1Unless explicitly specified when creating a
Workerinstance, eachWorkerthread has its own copy ofprocess.env, based on its parent thread'sprocess.env, or whatever was specified as theenvoption to theWorkerconstructor. Changes toprocess.envwill not be visible acrossWorkerthreads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy ofprocess.envon aWorkerinstance operates in a case-sensitive manner unlike the main thread.PULL_REQUEST),stringbody:body: Alchemy.Input<string>Comment body (supports GitHub Markdown).
The body is automatically dedented, so you can use indented template literals without worrying about leading whitespace. Accepts
Output<string>at the call site viaOutput.interpolateto embed resource attributes that are not yet resolved.Output.import Outputinterpolate`const interpolate: <Args extends any[]>(template: TemplateStringsArray, ...args: Args) => Output.All<Args> extends Alchemy.Output<any, infer Req> ? Alchemy.Output<string, Req> : never## Preview Deployed**URL:** ${app.const app: anyurl}anyBuilt from commit ${process.var process: NodeJS.Processenv.NodeJS.Process.env: NodeJS.ProcessEnvThe
process.envproperty returns an object containing the user environment. Seeenviron(7).An example of this object looks like:
{TERM: 'xterm-256color',SHELL: '/usr/local/bin/bash',USER: 'maciej',PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',PWD: '/Users/maciej',EDITOR: 'vim',SHLVL: '1',HOME: '/Users/maciej',LOGNAME: 'maciej',_: '/usr/local/bin/node'}It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other
Workerthreads. In other words, the following example would not work:Terminal window node -e 'process.env.foo = "bar"' && echo $fooWhile the following will:
import { env } from 'node:process';env.foo = 'bar';console.log(env.foo);Assigning a property on
process.envwill implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.import { env } from 'node:process';env.test = null;console.log(env.test);// => 'null'env.test = undefined;console.log(env.test);// => 'undefined'Use
deleteto delete a property fromprocess.env.import { env } from 'node:process';env.TEST = 1;delete env.TEST;console.log(env.TEST);// => undefinedOn Windows operating systems, environment variables are case-insensitive.
import { env } from 'node:process';env.TEST = 1;console.log(env.test);// => 1Unless explicitly specified when creating a
Workerinstance, eachWorkerthread has its own copy ofprocess.env, based on its parent thread'sprocess.env, or whatever was specified as theenvoption to theWorkerconstructor. Changes toprocess.envwill not be visible acrossWorkerthreads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy ofprocess.envon aWorkerinstance operates in a case-sensitive manner unlike the main thread.GITHUB_SHA?.string | undefinedslice(0, 7)}String.slice(start?: number, end?: number): stringReturns a section of a string.
---_This comment updates automatically with each push._`,});}return {url:url: anyapp.const app: anyurl };any}),); -
Create the base deployment workflow
Create
.github/workflows/deploy.yml. This workflow deploysprodon pushes tomainand apr-<number>stage for each pull request. When a PR is closed the preview environment is destroyed automatically.name: Deploy Applicationon:push:branches:- mainpull_request:types:- opened- reopened- synchronize- closedconcurrency:group: deploy-${{ github.ref }}cancel-in-progress: falseenv:STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}',github.event.number) || (github.ref == 'refs/heads/main' && 'prod' ||github.ref_name) }}jobs:deploy:if: ${{ github.event.action != 'closed' }}runs-on: ubuntu-latestpermissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup Bunuses: oven-sh/setup-bun@v2- name: Install dependenciesrun: bun install- name: Deployrun: bun alchemy deploy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}GITHUB_SHA: ${{ github.sha }}GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}cleanup:runs-on: ubuntu-latestif: ${{ github.event_name == 'pull_request' && github.event.action == 'closed'}}permissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup Bunuses: oven-sh/setup-bun@v2- name: Install dependenciesrun: bun install- name: Safety Checkrun: |-if [ "${{ env.STAGE }}" = "prod" ]; thenecho "ERROR: Cannot destroy prod environment in cleanup job"exit 1fi- name: Destroy Preview Environmentrun: bun alchemy destroy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}name: Deploy Applicationon:push:branches:- mainpull_request:types:- opened- reopened- synchronize- closedconcurrency:group: deploy-${{ github.ref }}cancel-in-progress: falseenv:STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}',github.event.number) || (github.ref == 'refs/heads/main' && 'prod' ||github.ref_name) }}jobs:deploy:if: ${{ github.event.action != 'closed' }}runs-on: ubuntu-latestpermissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: "24"- name: Install dependenciesrun: npm ci- name: Deployrun: npx alchemy deploy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}GITHUB_SHA: ${{ github.sha }}GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}cleanup:runs-on: ubuntu-latestif: ${{ github.event_name == 'pull_request' && github.event.action == 'closed'}}permissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: "24"- name: Install dependenciesrun: npm ci- name: Safety Checkrun: |-if [ "${{ env.STAGE }}" = "prod" ]; thenecho "ERROR: Cannot destroy prod environment in cleanup job"exit 1fi- name: Destroy Preview Environmentrun: npx alchemy destroy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}name: Deploy Applicationon:push:branches:- mainpull_request:types:- opened- reopened- synchronize- closedconcurrency:group: deploy-${{ github.ref }}cancel-in-progress: falseenv:STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}',github.event.number) || (github.ref == 'refs/heads/main' && 'prod' ||github.ref_name) }}jobs:deploy:if: ${{ github.event.action != 'closed' }}runs-on: ubuntu-latestpermissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup pnpmuses: pnpm/action-setup@v4with:version: "10"run_install: false- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: "24"cache: pnpm- name: Install dependenciesrun: pnpm install- name: Deployrun: pnpm dlx alchemy deploy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}GITHUB_SHA: ${{ github.sha }}GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}cleanup:runs-on: ubuntu-latestif: ${{ github.event_name == 'pull_request' && github.event.action == 'closed'}}permissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup pnpmuses: pnpm/action-setup@v4with:version: "10"run_install: false- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: "24"cache: pnpm- name: Install dependenciesrun: pnpm install- name: Safety Checkrun: |-if [ "${{ env.STAGE }}" = "prod" ]; thenecho "ERROR: Cannot destroy prod environment in cleanup job"exit 1fi- name: Destroy Preview Environmentrun: pnpm dlx alchemy destroy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}name: Deploy Applicationon:push:branches:- mainpull_request:types:- opened- reopened- synchronize- closedconcurrency:group: deploy-${{ github.ref }}cancel-in-progress: falseenv:STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}',github.event.number) || (github.ref == 'refs/heads/main' && 'prod' ||github.ref_name) }}jobs:deploy:if: ${{ github.event.action != 'closed' }}runs-on: ubuntu-latestpermissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: "24"cache: yarn- name: Install yarnrun: npm install -g yarn- name: Install dependenciesrun: yarn install- name: Deployrun: yarn dlx alchemy deploy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }}GITHUB_SHA: ${{ github.sha }}GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}cleanup:runs-on: ubuntu-latestif: ${{ github.event_name == 'pull_request' && github.event.action == 'closed'}}permissions:contents: readpull-requests: writesteps:- uses: actions/checkout@v4- name: Setup Node.jsuses: actions/setup-node@v4with:node-version: "24"cache: yarn- name: Install yarnrun: npm install -g yarn- name: Install dependenciesrun: yarn install- name: Safety Checkrun: |-if [ "${{ env.STAGE }}" = "prod" ]; thenecho "ERROR: Cannot destroy prod environment in cleanup job"exit 1fi- name: Destroy Preview Environmentrun: yarn dlx alchemy destroy --stage ${{ env.STAGE }}env:PULL_REQUEST: ${{ github.event.number }} -
Add provider credentials
The base workflow above doesn’t include any provider credentials yet. Pick the section below that matches your provider and apply the changes to your workflow.
GITHUB_TOKENis provided automatically by GitHub Actions and is used by theGitHub.Commentresource to post PR comments.
The GitHub Stack
Section titled “The GitHub Stack”Before CI can deploy, your repo needs provider credentials configured
as Actions secrets and variables. We recommend managing this with a
dedicated stacks/github.ts that you deploy once locally — that way
the credential set is reviewable, diffable, and reproducible.
The stack uses GitHub.Secret and GitHub.Variable to write into
your repository, and provider resources like
Cloudflare.AccountApiToken or AWS.IAM.Role to mint the
credentials themselves.
Set up an admin profile
Section titled “Set up an admin profile”The stacks/github.ts you’re about to write needs more privilege
than your day-to-day app stack. Creating a Cloudflare API token
requires API Tokens > Write; creating an IAM role and OIDC
provider requires IAM admin rights.
Rather than hand those rights to your default profile, create a
separate admin profile:
alchemy login --profile adminAfter creating the file, deploy it once locally under the admin profile:
alchemy deploy stacks/github.ts --profile adminYou only need to re-run this stack when you want to rotate credentials or change permissions.
Cloudflare
Section titled “Cloudflare”The simplest case: a single Cloudflare account, used for both prod
and PR previews. Your admin profile mints a scoped API token, and
the stack pushes it into your repo as a GitHub Actions secret.
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as GitHub from "alchemy/GitHub";import * as Config from "effect/Config";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Redacted from "effect/Redacted";
export default Alchemy.Stack( "github", { providers: Layer.mergeAll( Cloudflare.providers(), GitHub.providers(), ), state: Cloudflare.state(), }, Effect.gen(function* () { const accountId = yield* Config.string("CLOUDFLARE_ACCOUNT_ID");
const apiToken = yield* Cloudflare.AccountApiToken("CIToken", { accountId, policies: [ { effect: "allow", permissionGroups: [ "Workers Scripts Write", "Workers KV Storage Write", "Workers R2 Storage Write", "D1 Write", "Queues Write", "Pages Write", "Account Settings Write", "Secrets Store Write", "Workers Tail Read", ], resources: { [`com.cloudflare.api.account.${accountId}`]: "*", }, }, ], });
yield* GitHub.Secret("cf-api-token", { owner: "your-org", repository: "your-repo", name: "CLOUDFLARE_API_TOKEN", value: apiToken.value, });
yield* GitHub.Secret("cf-account-id", { owner: "your-org", repository: "your-repo", name: "CLOUDFLARE_ACCOUNT_ID", value: Redacted.make(accountId), }); }),);A few details worth knowing:
Cloudflare.AccountApiTokencallsPOST /accounts/{account_id}/tokensand Cloudflare returns the freshly minted token value exactly once. Alchemy captures it, stores it in state, and pipes it straight intoGitHub.Secret— the raw token never appears in your terminal or in CI logs.- Trim the
permissionGroupslist to just what your app needs. Listed above is a sensible default for a Workers + KV + R2 + D1 app. - The resource diff is stable across redeploys. Editing a policy produces a clean update against the same token ID.
Then add the secrets to your workflow’s deploy and cleanup steps:
- name: Deploy run: bun alchemy deploy --stage ${{ env.STAGE }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} PULL_REQUEST: ${{ github.event.number }} GITHUB_SHA: ${{ github.sha }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}- name: Destroy Preview Environment run: bun alchemy destroy --stage ${{ env.STAGE }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} PULL_REQUEST: ${{ github.event.number }}Optional: separate test and prod accounts
Section titled “Optional: separate test and prod accounts”For stricter isolation you can run preview environments on one
Cloudflare account and production on another. Mint two tokens from
the same stacks/github.ts and prefix the secrets so the workflow
can pick the right pair based on STAGE.
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as GitHub from "alchemy/GitHub";import * as Config from "effect/Config";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Redacted from "effect/Redacted";
export default Alchemy.Stack( "github", { providers: Layer.mergeAll( Cloudflare.providers(), GitHub.providers(), ), state: Cloudflare.state(), }, Effect.gen(function* () { const testAccountId = yield* Config.string("TEST_CLOUDFLARE_ACCOUNT_ID"); const prodAccountId = yield* Config.string("PROD_CLOUDFLARE_ACCOUNT_ID");
const policies = (accountId: string) => [ { effect: "allow" as const, permissionGroups: [ "Workers Scripts Write", "Workers KV Storage Write", "Workers R2 Storage Write", "D1 Write", "Queues Write", "Pages Write", "Account Settings Write", "Secrets Store Write", "Workers Tail Read", ], resources: { [`com.cloudflare.api.account.${accountId}`]: "*" }, }, ];
const testToken = yield* Cloudflare.AccountApiToken("TestApiToken", { accountId: testAccountId, policies: policies(testAccountId), });
const prodToken = yield* Cloudflare.AccountApiToken("ProdApiToken", { accountId: prodAccountId, policies: policies(prodAccountId), });
const secrets: Record<string, Redacted.Redacted<string>> = { TEST_CLOUDFLARE_API_TOKEN: testToken.value, TEST_CLOUDFLARE_ACCOUNT_ID: Redacted.make(testAccountId), PROD_CLOUDFLARE_API_TOKEN: prodToken.value, PROD_CLOUDFLARE_ACCOUNT_ID: Redacted.make(prodAccountId), };
yield* Effect.all( Object.entries(secrets).map(([name, value]) => GitHub.Secret(name, { owner: "your-org", repository: "your-repo", name, value, }), ), ); }),);Your admin profile needs API-token-write permission on both
accounts. The simplest setup is to log in with the Global API Key of
a user that’s a member of both.
In your workflow, switch on STAGE to choose the right credential
pair:
- name: Deploy run: bun alchemy deploy --stage ${{ env.STAGE }} env: CLOUDFLARE_API_TOKEN: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_API_TOKEN || secrets.TEST_CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_ACCOUNT_ID || secrets.TEST_CLOUDFLARE_ACCOUNT_ID }} PULL_REQUEST: ${{ github.event.number }} GITHUB_SHA: ${{ github.sha }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}AWS with GitHub OIDC (recommended)
Section titled “AWS with GitHub OIDC (recommended)”GitHub OIDC lets your workflow assume an IAM role without storing long-lived access keys. The GitHub stack creates the OIDC provider and an IAM role scoped to your repo, then writes the role ARN and region to the repo as Actions variables (not secrets — they’re not sensitive).
Your --profile admin AWS credentials need IAM admin rights for
this stack: it creates an OpenIDConnectProvider and an IAM Role.
Run alchemy login --profile admin and choose a credential
(SSO/OIDC, access keys, etc.) that’s authorized for IAM
administration.
import * as Alchemy from "alchemy";import * as AWS from "alchemy/AWS";import * as GitHub from "alchemy/GitHub";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "github", { providers: Layer.mergeAll( AWS.providers(), GitHub.providers(), ), }, Effect.gen(function* () { const oidc = yield* AWS.IAM.OpenIDConnectProvider("GitHubOidc", { url: "https://token.actions.githubusercontent.com", clientIDList: ["sts.amazonaws.com"], });
const role = yield* AWS.IAM.Role("GitHubActionsRole", { roleName: "github-actions", assumeRolePolicyDocument: { Version: "2012-10-17", Statement: [ { Effect: "Allow", Principal: { Federated: oidc.openIDConnectProviderArn, }, Action: "sts:AssumeRoleWithWebIdentity", Condition: { StringEquals: { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", }, StringLike: { "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*", }, }, }, ], }, managedPolicyArns: [ "arn:aws:iam::aws:policy/AdministratorAccess", ], });
const region = yield* AWS.Region;
yield* GitHub.Variable("aws-role-arn", { owner: "your-org", repository: "your-repo", name: "AWS_ROLE_ARN", value: role.roleArn, });
yield* GitHub.Variable("aws-region", { owner: "your-org", repository: "your-repo", name: "AWS_REGION", value: region, }); }),);After deploying, update the workflow to add id-token: write and
the configure-aws-credentials step. No secrets needed:
deploy: permissions: id-token: write contents: read pull-requests: write steps: - uses: actions/checkout@v4 # ...setup and install... - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - name: Deploy run: bun alchemy deploy --stage ${{ env.STAGE }}cleanup: permissions: id-token: write contents: read pull-requests: write steps: - uses: actions/checkout@v4 # ...setup, install, and safety check... - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ROLE_ARN }} aws-region: ${{ vars.AWS_REGION }} - name: Destroy Preview Environment run: bun alchemy destroy --stage ${{ env.STAGE }}AWS with access keys
Section titled “AWS with access keys”If OIDC isn’t an option (some sandbox accounts disallow it, or you’re targeting an account you don’t fully control), fall back to static IAM access keys stored as repo secrets. The stack pushes existing keys from your environment into the repo — it does not mint a new IAM user, since most teams prefer to do that step in the AWS console.
import * as Alchemy from "alchemy";import * as GitHub from "alchemy/GitHub";import * as Config from "effect/Config";import * as Effect from "effect/Effect";import * as Redacted from "effect/Redacted";
export default Alchemy.Stack( "github", { providers: GitHub.providers(), }, Effect.gen(function* () { const accessKeyId = yield* Config.string("AWS_ACCESS_KEY_ID"); const secretAccessKey = yield* Config.redacted("AWS_SECRET_ACCESS_KEY");
yield* GitHub.Secret("aws-access-key-id", { owner: "your-org", repository: "your-repo", name: "AWS_ACCESS_KEY_ID", value: Redacted.make(accessKeyId), });
yield* GitHub.Secret("aws-secret-access-key", { owner: "your-org", repository: "your-repo", name: "AWS_SECRET_ACCESS_KEY", value: secretAccessKey, });
yield* GitHub.Variable("aws-region", { owner: "your-org", repository: "your-repo", name: "AWS_REGION", value: "us-east-1", }); }),);Then add the secrets to your workflow’s deploy and cleanup steps:
- name: Deploy run: bun alchemy deploy --stage ${{ env.STAGE }} env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} PULL_REQUEST: ${{ github.event.number }} GITHUB_SHA: ${{ github.sha }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}- name: Destroy Preview Environment run: bun alchemy destroy --stage ${{ env.STAGE }} env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} PULL_REQUEST: ${{ github.event.number }}