| 1 | --- |
| 2 | title: "Part 5: CI/CD" |
| 3 | description: Set up GitHub Actions for automated deployments, PR previews, and remote state — with Cloudflare credentials managed as code. |
| 4 | sidebar: |
| 5 | order: 5 |
| 6 | --- |
| 7 | |
| 8 | import { Steps, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 9 | |
| 10 | In [Part 4](/cloudflare/tutorial/part-4) you ran your stack locally. Now you'll |
| 11 | hand it off to GitHub Actions: pushes to `main` deploy to production, |
| 12 | pull requests get isolated preview environments, and merged PRs clean |
| 13 | up after themselves. |
| 14 | |
| 15 | The trick to doing this safely is **letting Alchemy provision its own |
| 16 | CI credentials**. Instead of pasting your personal Cloudflare API key |
| 17 | into GitHub, you'll write a small `stacks/github.ts` that mints a |
| 18 | scoped Cloudflare API token, then stores it as a GitHub Actions |
| 19 | secret — all from code, all checked in. |
| 20 | |
| 21 | ## Confirm your state store is remote |
| 22 | |
| 23 | CI needs to share state across runs, so a local `.alchemy/` directory |
| 24 | won't cut it. You already configured `Cloudflare.state()` back in |
| 25 | [Part 1](/cloudflare/tutorial/part-1#configure-state), which deploys a Worker |
| 26 | backed by a Durable Object with SQLite. Every deploy — local or |
| 27 | from CI — reads and writes state remotely through that Worker. |
| 28 | |
| 29 | Double-check it's still wired up in `alchemy.run.ts`: |
| 30 | |
| 31 | ```typescript |
| 32 | // alchemy.run.ts |
| 33 | export default Alchemy.Stack( |
| 34 | "MyApp", |
| 35 | { |
| 36 | providers: Cloudflare.providers(), |
| 37 | state: Cloudflare.state(), |
| 38 | }, |
| 39 | // ... |
| 40 | ); |
| 41 | ``` |
| 42 | |
| 43 | :::caution |
| 44 | If you're using `State.localState()` instead, switch it to |
| 45 | `Cloudflare.state()` (or another shared store) before continuing — |
| 46 | GitHub Actions runners can't see your local filesystem. |
| 47 | ::: |
| 48 | |
| 49 | ## Set up an `admin` profile |
| 50 | |
| 51 | Your `alchemy.run.ts` only needs enough permission to deploy your |
| 52 | app. The `stacks/github.ts` you're about to write needs more: it |
| 53 | creates a brand-new Cloudflare API token, which requires the |
| 54 | `API Tokens > Write` permission. |
| 55 | |
| 56 | Rather than hand those elevated rights to your day-to-day profile, |
| 57 | create a dedicated `admin` profile: |
| 58 | |
| 59 | ```sh |
| 60 | alchemy profile create admin |
| 61 | alchemy profile edit --profile admin --add Cloudflare |
| 62 | ``` |
| 63 | |
| 64 | When prompted for a Cloudflare credential, pick one of: |
| 65 | |
| 66 | - **API Key + Email** (the Global API Key) — has full account |
| 67 | access, including the ability to mint tokens. |
| 68 | - **API Token** — must be a token with at least |
| 69 | `User > API Tokens > Write` (for user-owned tokens) **and** |
| 70 | `Account > API Tokens > Write` (for the account whose tokens |
| 71 | you'll be creating). Most people use the Global API Key here |
| 72 | because the token-permissions UI in the Cloudflare dashboard is |
| 73 | fiddly. |
| 74 | |
| 75 | :::danger[Heads up] |
| 76 | The credential stored under `--profile admin` can create new API |
| 77 | tokens with arbitrary scopes. Treat it like root. Do **not** use it |
| 78 | for everyday `alchemy deploy` runs against your application — keep |
| 79 | your default profile on a narrowly scoped API Token (or OAuth) and |
| 80 | only reach for `admin` when you're touching `stacks/github.ts`. |
| 81 | ::: |
| 82 | |
| 83 | You'll also be prompted for a GitHub credential. Pick `gh-cli` if |
| 84 | you have the GitHub CLI installed, otherwise paste a Personal |
| 85 | Access Token with the `repo` scope. |
| 86 | |
| 87 | ## Scaffold the GitHub stack |
| 88 | |
| 89 | Create `stacks/github.ts` with an empty stack that loads both |
| 90 | provider layers and reuses your remote state store. This is a |
| 91 | one-shot stack you'll deploy from your laptop (under the `admin` |
| 92 | profile) to provision the Cloudflare API token CI will use. |
| 93 | |
| 94 | ```typescript |
| 95 | // stacks/github.ts |
| 96 | import * as Alchemy from "alchemy"; |
| 97 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 98 | import * as GitHub from "alchemy/GitHub"; |
| 99 | import * as Effect from "effect/Effect"; |
| 100 | import * as Layer from "effect/Layer"; |
| 101 | import * as Redacted from "effect/Redacted"; |
| 102 | |
| 103 | export default Alchemy.Stack( |
| 104 | "github", |
| 105 | { |
| 106 | providers: Layer.mergeAll( |
| 107 | Cloudflare.providers(), |
| 108 | GitHub.providers(), |
| 109 | ), |
| 110 | state: Cloudflare.state(), |
| 111 | }, |
| 112 | Effect.gen(function* () { |
| 113 | const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; |
| 114 | }), |
| 115 | ); |
| 116 | ``` |
| 117 | |
| 118 | `Cloudflare.CloudflareEnvironment` resolves the credentials of the |
| 119 | profile you're deploying with — here, the `admin` profile — and the |
| 120 | account ID comes along with them. You don't need to set a |
| 121 | `CLOUDFLARE_ACCOUNT_ID` environment variable; your login already |
| 122 | knows it. |
| 123 | |
| 124 | Note the double `yield*`: the service's value is itself an Effect, |
| 125 | because credentials are cached and can refresh themselves (OAuth |
| 126 | tokens expire). The first `yield*` gets the service, the second |
| 127 | resolves the current credentials. |
| 128 | |
| 129 | Reusing the same `Cloudflare.state()` you set up in Part 1 means the |
| 130 | token's ID is tracked alongside the rest of your infrastructure, so |
| 131 | any future edit (rename, policy change) produces a clean diff. |
| 132 | |
| 133 | ## Mint a scoped Cloudflare API token |
| 134 | |
| 135 | Add an `AccountApiToken` resource. Under the hood it calls |
| 136 | `POST /accounts/{account_id}/tokens` and returns the freshly minted |
| 137 | secret. Cloudflare only reveals that value once, so Alchemy stores it |
| 138 | in state — the raw token never touches your terminal. |
| 139 | |
| 140 | ```diff lang="typescript" |
| 141 | Effect.gen(function* () { |
| 142 | const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; |
| 143 | + |
| 144 | + const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("CIToken", { |
| 145 | + accountId, |
| 146 | + policies: [ |
| 147 | + { |
| 148 | + effect: "allow", |
| 149 | + permissionGroups: [ |
| 150 | + "Secrets Store Write", |
| 151 | + "Workers Scripts Write", |
| 152 | + "Workers KV Storage Write", |
| 153 | + "Workers R2 Storage Write", |
| 154 | + "D1 Write", |
| 155 | + "Queues Write", |
| 156 | + "Pages Write", |
| 157 | + "Account Settings Write", |
| 158 | + "Workers Tail Read", |
| 159 | + ], |
| 160 | + resources: { |
| 161 | + [`com.cloudflare.api.account.${accountId}`]: "*", |
| 162 | + }, |
| 163 | + }, |
| 164 | + ], |
| 165 | + }); |
| 166 | }), |
| 167 | ``` |
| 168 | |
| 169 | The `permissionGroups` list scopes the token down to exactly what |
| 170 | your app needs. If you don't use D1 or R2, drop those entries. |
| 171 | |
| 172 | ## Push the token into GitHub |
| 173 | |
| 174 | Pipe `apiToken.value` straight into a `GitHub.Secret` so CI can read |
| 175 | it without the secret ever round-tripping through your shell. |
| 176 | |
| 177 | ```diff lang="typescript" |
| 178 | }); |
| 179 | + |
| 180 | + yield* GitHub.Secret("cf-api-token", { |
| 181 | + owner: "your-org", |
| 182 | + repository: "your-repo", |
| 183 | + name: "CLOUDFLARE_API_TOKEN", |
| 184 | + value: apiToken.value, |
| 185 | + }); |
| 186 | }), |
| 187 | ``` |
| 188 | |
| 189 | Replace `your-org` and `your-repo` with your GitHub org and repo |
| 190 | slug. |
| 191 | |
| 192 | ## Expose the account ID to CI |
| 193 | |
| 194 | The workflow also needs `CLOUDFLARE_ACCOUNT_ID`. It isn't a secret in |
| 195 | the cryptographic sense, but storing it as a GitHub Actions secret |
| 196 | keeps all CI configuration in one place and out of your workflow YAML. |
| 197 | |
| 198 | ```diff lang="typescript" |
| 199 | value: apiToken.value, |
| 200 | }); |
| 201 | + |
| 202 | + yield* GitHub.Secret("cf-account-id", { |
| 203 | + owner: "your-org", |
| 204 | + repository: "your-repo", |
| 205 | + name: "CLOUDFLARE_ACCOUNT_ID", |
| 206 | + value: Redacted.make(accountId), |
| 207 | + }); |
| 208 | }), |
| 209 | ``` |
| 210 | |
| 211 | `Redacted.make` wraps the plain string so it gets the same masking |
| 212 | treatment as the API token. |
| 213 | |
| 214 | ## Deploy the GitHub stack |
| 215 | |
| 216 | Run it once from your laptop, under the `admin` profile: |
| 217 | |
| 218 | ```sh |
| 219 | alchemy deploy --config stacks/github.ts --profile admin |
| 220 | ``` |
| 221 | |
| 222 | You'll see Alchemy plan three creates — the Cloudflare token and the |
| 223 | two GitHub secrets — then apply them. When it's done, head to your repo's |
| 224 | **Settings → Secrets and variables → Actions** page; you should see |
| 225 | `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` listed there. |
| 226 | |
| 227 | You only need to re-run this stack when you want to rotate the token |
| 228 | or change its permissions. |
| 229 | |
| 230 | ## Create the workflow |
| 231 | |
| 232 | Create `.github/workflows/deploy.yml`: |
| 233 | |
| 234 | ```yaml |
| 235 | name: Deploy |
| 236 | on: |
| 237 | push: |
| 238 | branches: [main] |
| 239 | pull_request: |
| 240 | types: [opened, reopened, synchronize, closed] |
| 241 | |
| 242 | concurrency: |
| 243 | group: deploy-${{ github.ref }} |
| 244 | cancel-in-progress: false |
| 245 | |
| 246 | env: |
| 247 | STAGE: >- |
| 248 | ${{ github.event_name == 'pull_request' |
| 249 | && format('pr-{0}', github.event.number) |
| 250 | || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }} |
| 251 | |
| 252 | jobs: |
| 253 | deploy: |
| 254 | if: github.event.action != 'closed' |
| 255 | runs-on: ubuntu-latest |
| 256 | permissions: |
| 257 | contents: read |
| 258 | pull-requests: write |
| 259 | steps: |
| 260 | - uses: actions/checkout@v4 |
| 261 | - uses: oven-sh/setup-bun@v2 |
| 262 | - run: bun install |
| 263 | - name: Deploy |
| 264 | run: bun alchemy deploy --stage ${{ env.STAGE }} --yes |
| 265 | env: |
| 266 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 267 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 268 | PULL_REQUEST: ${{ github.event.number }} |
| 269 | GITHUB_SHA: ${{ github.sha }} |
| 270 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 271 | |
| 272 | cleanup: |
| 273 | if: github.event_name == 'pull_request' && github.event.action == 'closed' |
| 274 | runs-on: ubuntu-latest |
| 275 | permissions: |
| 276 | contents: read |
| 277 | pull-requests: write |
| 278 | steps: |
| 279 | - uses: actions/checkout@v4 |
| 280 | - uses: oven-sh/setup-bun@v2 |
| 281 | - run: bun install |
| 282 | - name: Safety Check |
| 283 | run: | |
| 284 | if [ "${{ env.STAGE }}" = "prod" ]; then |
| 285 | echo "ERROR: Cannot destroy prod environment in cleanup job" |
| 286 | exit 1 |
| 287 | fi |
| 288 | - name: Destroy Preview |
| 289 | run: bun alchemy destroy --stage ${{ env.STAGE }} --yes |
| 290 | env: |
| 291 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 292 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 293 | PULL_REQUEST: ${{ github.event.number }} |
| 294 | ``` |
| 295 | |
| 296 | :::note[How STAGE is computed] |
| 297 | |
| 298 | - Pull requests → `pr-{number}` (e.g. `pr-42`) |
| 299 | - Push to `main` → `prod` |
| 300 | - Other branches → the branch name |
| 301 | |
| 302 | The cleanup job includes a safety check so `prod` can never be |
| 303 | accidentally destroyed. |
| 304 | ::: |
| 305 | |
| 306 | ## Add PR preview comments |
| 307 | |
| 308 | Post the live URL as a comment on every PR. Update `alchemy.run.ts`: |
| 309 | |
| 310 | ```diff lang="typescript" |
| 311 | // alchemy.run.ts |
| 312 | import * as Alchemy from "alchemy"; |
| 313 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 314 | +import * as GitHub from "alchemy/GitHub"; |
| 315 | +import * as Output from "alchemy/Output"; |
| 316 | import * as Effect from "effect/Effect"; |
| 317 | +import * as Layer from "effect/Layer"; |
| 318 | import { Bucket } from "./src/bucket.ts"; |
| 319 | import Worker from "./src/worker.ts"; |
| 320 | |
| 321 | export default Alchemy.Stack( |
| 322 | "MyApp", |
| 323 | { |
| 324 | - providers: Cloudflare.providers(), |
| 325 | + providers: Layer.mergeAll( |
| 326 | + Cloudflare.providers(), |
| 327 | + GitHub.providers(), |
| 328 | + ), |
| 329 | state: Cloudflare.state(), |
| 330 | }, |
| 331 | Effect.gen(function* () { |
| 332 | const bucket = yield* Bucket; |
| 333 | const worker = yield* Worker; |
| 334 | |
| 335 | + if (process.env.PULL_REQUEST) { |
| 336 | + yield* GitHub.Comment("preview-comment", { |
| 337 | + owner: "your-org", |
| 338 | + repository: "your-repo", |
| 339 | + issueNumber: Number(process.env.PULL_REQUEST), |
| 340 | + body: Output.interpolate` |
| 341 | + ## Preview Deployed |
| 342 | + |
| 343 | + **URL:** ${worker.url} |
| 344 | + |
| 345 | + Built from commit ${process.env.GITHUB_SHA?.slice(0, 7)} |
| 346 | + |
| 347 | + --- |
| 348 | + _This comment updates automatically with each push._ |
| 349 | + `, |
| 350 | + }); |
| 351 | + } |
| 352 | |
| 353 | return { |
| 354 | bucketName: bucket.bucketName, |
| 355 | url: worker.url, |
| 356 | }; |
| 357 | }), |
| 358 | ); |
| 359 | ``` |
| 360 | |
| 361 | The logical ID `"preview-comment"` stays the same across pushes, so |
| 362 | Alchemy updates the existing comment instead of creating a new one. |
| 363 | `GITHUB_TOKEN` is provided automatically by Actions and authorizes |
| 364 | the comment. |
| 365 | |
| 366 | ## The full picture |
| 367 | |
| 368 | Here's how everything fits together: |
| 369 | |
| 370 | 1. You ran `stacks/github.ts` once under `--profile admin`. That |
| 371 | minted a scoped Cloudflare API token and pushed it into your |
| 372 | repo as a GitHub Actions secret. |
| 373 | 2. A developer pushes a branch and opens a PR. |
| 374 | 3. GitHub Actions checks out the code, installs deps, and runs |
| 375 | `alchemy deploy --stage pr-42` using the CI token. |
| 376 | 4. Alchemy creates an isolated copy of every resource (bucket, |
| 377 | worker, etc.) under the `pr-42` stage and posts the preview URL |
| 378 | as a PR comment. |
| 379 | 5. The reviewer clicks the URL and tests the preview. |
| 380 | 6. The PR is merged — the cleanup job runs |
| 381 | `alchemy destroy --stage pr-42` and the preview infrastructure |
| 382 | disappears. |
| 383 | 7. The push to `main` deploys `--stage prod`. |
| 384 | |
| 385 | Each environment is fully isolated with its own bucket, worker, and |
| 386 | state. |
| 387 | |
| 388 | ## Optional: separate test and prod Cloudflare accounts |
| 389 | |
| 390 | If you want to keep preview environments on one Cloudflare account |
| 391 | and production on another, deploy two tokens from the same |
| 392 | `stacks/github.ts` and prefix the secrets accordingly. |
| 393 | |
| 394 | Your profile resolves a single account ID, so this is the one case |
| 395 | where you pass the account IDs in explicitly — set |
| 396 | `TEST_CLOUDFLARE_ACCOUNT_ID` and `PROD_CLOUDFLARE_ACCOUNT_ID` in |
| 397 | your environment when deploying this stack. |
| 398 | |
| 399 | ```typescript |
| 400 | // stacks/github.ts |
| 401 | import * as Alchemy from "alchemy"; |
| 402 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 403 | import * as GitHub from "alchemy/GitHub"; |
| 404 | import * as Config from "effect/Config"; |
| 405 | import * as Effect from "effect/Effect"; |
| 406 | import * as Layer from "effect/Layer"; |
| 407 | import * as Redacted from "effect/Redacted"; |
| 408 | |
| 409 | export default Alchemy.Stack( |
| 410 | "github", |
| 411 | { |
| 412 | providers: Layer.mergeAll( |
| 413 | Cloudflare.providers(), |
| 414 | GitHub.providers(), |
| 415 | ), |
| 416 | state: Cloudflare.state(), |
| 417 | }, |
| 418 | Effect.gen(function* () { |
| 419 | const testAccountId = yield* Config.String("TEST_CLOUDFLARE_ACCOUNT_ID"); |
| 420 | const prodAccountId = yield* Config.String("PROD_CLOUDFLARE_ACCOUNT_ID"); |
| 421 | |
| 422 | const testToken = yield* Cloudflare.ApiToken.AccountApiToken("TestApiToken", { |
| 423 | accountId: testAccountId, |
| 424 | policies: [/* same policies as above */], |
| 425 | }); |
| 426 | |
| 427 | const prodToken = yield* Cloudflare.ApiToken.AccountApiToken("ProdApiToken", { |
| 428 | accountId: prodAccountId, |
| 429 | policies: [/* same policies as above */], |
| 430 | }); |
| 431 | |
| 432 | const secrets: Record<string, Redacted.Redacted<string>> = { |
| 433 | TEST_CLOUDFLARE_API_TOKEN: testToken.value, |
| 434 | TEST_CLOUDFLARE_ACCOUNT_ID: Redacted.make(testAccountId), |
| 435 | PROD_CLOUDFLARE_API_TOKEN: prodToken.value, |
| 436 | PROD_CLOUDFLARE_ACCOUNT_ID: Redacted.make(prodAccountId), |
| 437 | }; |
| 438 | |
| 439 | yield* Effect.all( |
| 440 | Object.entries(secrets).map(([name, value]) => |
| 441 | GitHub.Secret(name, { |
| 442 | owner: "your-org", |
| 443 | repository: "your-repo", |
| 444 | name, |
| 445 | value, |
| 446 | }), |
| 447 | ), |
| 448 | ); |
| 449 | }), |
| 450 | ); |
| 451 | ``` |
| 452 | |
| 453 | In your workflow, choose which set of credentials to expose based |
| 454 | on `STAGE`: |
| 455 | |
| 456 | ```yaml |
| 457 | env: |
| 458 | CLOUDFLARE_API_TOKEN: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_API_TOKEN || secrets.TEST_CLOUDFLARE_API_TOKEN }} |
| 459 | CLOUDFLARE_ACCOUNT_ID: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_ACCOUNT_ID || secrets.TEST_CLOUDFLARE_ACCOUNT_ID }} |
| 460 | ``` |
| 461 | |
| 462 | Your `admin` profile needs API-token-write permission on **both** |
| 463 | accounts. The simplest way is to log in with the Global API Key of |
| 464 | a user that's a member of both. |
| 465 | |
| 466 | ## Recap |
| 467 | |
| 468 | You now know how to: |
| 469 | |
| 470 | - **Part 1** — Create a Stack and deploy a resource |
| 471 | - **Part 2** — Add a Worker with bindings to other resources |
| 472 | - **Part 3** — Write integration tests against deployed stacks |
| 473 | - **Part 4** — Run locally with `alchemy dev` |
| 474 | - **Part 5** — Mint scoped CI credentials with |
| 475 | `Cloudflare.ApiToken.AccountApiToken`, push them to GitHub with |
| 476 | `GitHub.Secret`, and deploy from Actions |
| 477 | |
| 478 | In [Part 6](/cloudflare/tutorial/part-6), you'll ship the Worker's |
| 479 | traces and logs to Axiom — with the datasets and ingest token |
| 480 | declared in the same Stack as the Worker they observe. |
| 481 | |
| 482 | ## What's next |
| 483 | |
| 484 | - Go deeper on Cloudflare in the |
| 485 | [Cloudflare track](/cloudflare/compute/durable-objects) — layer |
| 486 | Durable Objects, hibernatable WebSockets, Containers, Workflows, |
| 487 | and Queues onto the Worker you built |
| 488 | - Read the [Resource Lifecycle](/infrastructure-as-code/resource-lifecycle) |
| 489 | guide for all CLI flags and options |
| 490 | - Explore the [CI guide](/environments/ci) for the AWS equivalents |
| 491 | (OIDC and access keys) and more workflow patterns |
| 492 | - Check out the [Testing a Stack](/testing/testing-a-stack) guide for |
| 493 | more advanced testing patterns |
| 494 | - Browse the [Providers](/providers) reference for all available |
| 495 | resources |