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.
Add a remote state store
Section titled “Add a remote state store”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:
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.
alchemy deployAlso add .alchemy/ to your .gitignore:
echo ".alchemy/" >> .gitignoreStore credentials in GitHub
Section titled “Store credentials in GitHub”-
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),});}),); -
Deploy the GitHub stack once locally
Terminal window alchemy deploy stacks/github.tsThis reads your local environment variables and writes them as encrypted secrets to your GitHub repository.
Create the workflow
Section titled “Create the workflow”Create .github/workflows/deploy.yml:
name: Deployon: 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 }}Add PR preview comments
Section titled “Add PR preview comments”Post the live URL as a comment on every PR. Update 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.
The full picture
Section titled “The full picture”Here’s how everything fits together:
- Developer pushes a branch and opens a PR
- GitHub Actions deploys
--stage pr-42, creating isolated infrastructure - Alchemy posts a comment with the preview URL
- Reviewer clicks the URL and tests the preview
- PR is merged — the cleanup job destroys the
pr-42stage - Push to
maindeploys--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
What’s next
Section titled “What’s next”- Read the Plan, Deploy and Destroy guide for all CLI flags and options
- Explore the CI guide for AWS credential setup (OIDC and access keys)
- Check out the Testing guide for more advanced testing patterns
- Browse the Providers reference for all available resources