Skip to content

Part 5: CI/CD

In Part 4 you ran your stack locally. Now you’ll set up GitHub Actions so that pushes to main deploy to production, pull requests get isolated preview environments, and merged PRs clean up after themselves.

So far, Alchemy has been storing state locally in the .alchemy/ directory. That works for solo development, but CI needs to share state across runs. Add Cloudflare.state() to your Stack:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { Bucket } from "./src/bucket.ts";
import Worker from "./src/worker.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const bucket = yield* Bucket;
const worker = yield* Worker;
return {
bucketName: bucket.bucketName,
url: worker.url,
};
}),
);

The next deploy creates a state Worker on Cloudflare backed by a Durable Object with SQLite. From that point on, every deploy reads and writes state remotely — no shared filesystem needed.

Terminal window
alchemy deploy

Also add .alchemy/ to your .gitignore:

Terminal window
echo ".alchemy/" >> .gitignore
  1. Create a GitHub stack

    Use Alchemy to manage your repository secrets as code. Create stacks/github.ts:

    stacks/github.ts
    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),
    });
    }),
    );
  2. Deploy the GitHub stack once locally

    Terminal window
    alchemy deploy stacks/github.ts

    This reads your local environment variables and writes them as encrypted secrets to your GitHub repository.

Create .github/workflows/deploy.yml:

name: Deploy
on:
push:
branches: [main]
pull_request:
types: [opened, reopened, synchronize, closed]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
env:
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-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- 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 }}
cleanup:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- name: Safety Check
run: |
if [ "${{ env.STAGE }}" = "prod" ]; then
echo "ERROR: Cannot destroy prod environment in cleanup job"
exit 1
fi
- name: Destroy Preview
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 }}

Post the live URL as a comment on every PR. Update alchemy.run.ts:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as GitHub from "alchemy/GitHub";
import * as Output from "alchemy/Output";
import * as Effect from "effect/Effect";
import { Bucket } from "./src/bucket.ts";
import Worker from "./src/worker.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const bucket = yield* Bucket;
const worker = yield* Worker;
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:** ${worker.url}
Built from commit ${process.env.GITHUB_SHA?.slice(0, 7)}
---
_This comment updates automatically with each push._
`,
});
}
return {
bucketName: bucket.bucketName,
url: worker.url,
};
}),
);

The logical ID "preview-comment" stays the same across pushes, so Alchemy updates the existing comment instead of creating a new one.

Here’s how everything fits together:

  1. Developer pushes a branch and opens a PR
  2. GitHub Actions deploys --stage pr-42, creating isolated infrastructure
  3. Alchemy posts a comment with the preview URL
  4. Reviewer clicks the URL and tests the preview
  5. PR is merged — the cleanup job destroys the pr-42 stage
  6. Push to main deploys --stage prod

Each environment is fully isolated with its own bucket, worker, and state.

You’ve completed the tutorial. You now know how to:

  • Part 1 — Create a Stack and deploy a resource
  • Part 2 — Add a Worker with bindings to other resources
  • Part 3 — Write integration tests against deployed stacks
  • Part 4 — Run locally with alchemy dev
  • Part 5 — Automate everything with GitHub Actions CI/CD