Continuous Integration
Set up preview deployments and continuous integration for your alchemy projects using GitHub Actions.
As part of this guide, we’ll:
- Create a GitHub Actions workflow with PR previews
- Post a GitHub Comment on each PR with the preview URL
- Create a GitHub Stack (
stacks/github.ts) that configures your repository with provider credentials usingGitHub.SecretandGitHub.Variable
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 * as Alchemy from "alchemy";import * as GitHub from "alchemy/GitHub";import * as Output from "alchemy/Output";import * as Effect from "effect/Effect";export default Alchemy.Stack("my-app",{ providers: /* ... */ },Effect.gen(function* () {const app = yield* App;if (process.env.PULL_REQUEST) {yield* GitHub.Comment("preview-comment", {owner: "your-org",repository: "your-repo",issueNumber: Number(process.env.PULL_REQUEST),body: Output.interpolate`## Preview Deployed**URL:** ${app.url}Built from commit ${process.env.GITHUB_SHA?.slice(0, 7)}---_This comment updates automatically with each push._`,});}return { url: app.url };}),); -
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.
GitHub Stack
Section titled “GitHub Stack”Before CI can deploy, your GitHub repository needs provider credentials
configured as secrets and variables. We recommend managing this with a
dedicated stacks/github.ts stack that you deploy once locally.
The base stack uses GitHub.Secret and GitHub.Variable to configure
your repository programmatically:
Pick the section below that matches your provider for a complete example.
After creating the file, deploy it once locally:
alchemy deploy stacks/github.tsCloudflare
Section titled “Cloudflare”Add your Cloudflare credentials to the GitHub stack:
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 Redacted from "effect/Redacted";
export default Alchemy.Stack( "github", { providers: Cloudflare.providers(), }, Effect.gen(function* () { const cfApiToken = yield* Config.string("CLOUDFLARE_API_TOKEN"); const cfAccountId = yield* Config.string("CLOUDFLARE_ACCOUNT_ID");
yield* GitHub.Secret("cf-api-token", { owner: "your-org", repository: "your-repo", name: "CLOUDFLARE_API_TOKEN", value: Redacted.make(cfApiToken), });
yield* GitHub.Secret("cf-account-id", { owner: "your-org", repository: "your-repo", name: "CLOUDFLARE_ACCOUNT_ID", value: Redacted.make(cfAccountId), }); }),);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 }}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 IAM role, then configures the repo with the role ARN and region as variables.
import * as Alchemy from "alchemy"; import * as AWS from "alchemy/AWS";import * as GitHub from "alchemy/GitHub";import * as Effect from "effect/Effect";
export default Alchemy.Stack( "github", { providers: AWS.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:
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 you can’t use OIDC, store static IAM access keys as repository secrets instead.
import * as Alchemy from "alchemy"; import * as AWS from "alchemy/AWS";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: AWS.providers(), }, Effect.gen(function* () { const accessKeyId = yield* Config.string("AWS_ACCESS_KEY_ID"); const secretAccessKey = yield* Config.string("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: Redacted.make(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 }}