1---
2title: "Part 5: CI/CD"
3description: Set up GitHub Actions for automated deployments, PR previews, and remote state — with Cloudflare credentials managed as code.
4sidebar:
5 order: 5
6---
7
8import { Steps, Tabs, TabItem } from "@astrojs/starlight/components";
9
10In [Part 4](/cloudflare/tutorial/part-4) you ran your stack locally. Now you'll
11hand it off to GitHub Actions: pushes to `main` deploy to production,
12pull requests get isolated preview environments, and merged PRs clean
13up after themselves.
14
15The trick to doing this safely is **letting Alchemy provision its own
16CI credentials**. Instead of pasting your personal Cloudflare API key
17into GitHub, you'll write a small `stacks/github.ts` that mints a
18scoped Cloudflare API token, then stores it as a GitHub Actions
19secret — all from code, all checked in.
20
21## Confirm your state store is remote
22
23CI needs to share state across runs, so a local `.alchemy/` directory
24won't cut it. You already configured `Cloudflare.state()` back in
25[Part 1](/cloudflare/tutorial/part-1#configure-state), which deploys a Worker
26backed by a Durable Object with SQLite. Every deploy — local or
27from CI — reads and writes state remotely through that Worker.
28
29Double-check it's still wired up in `alchemy.run.ts`:
30
31```typescript
32// alchemy.run.ts
33export default Alchemy.Stack(
34 "MyApp",
35 {
36 providers: Cloudflare.providers(),
37 state: Cloudflare.state(),
38 },
39 // ...
40);
41```
42
43:::caution
44If you're using `State.localState()` instead, switch it to
45`Cloudflare.state()` (or another shared store) before continuing —
46GitHub Actions runners can't see your local filesystem.
47:::
48
49## Set up an `admin` profile
50
51Your `alchemy.run.ts` only needs enough permission to deploy your
52app. The `stacks/github.ts` you're about to write needs more: it
53creates a brand-new Cloudflare API token, which requires the
54`API Tokens > Write` permission.
55
56Rather than hand those elevated rights to your day-to-day profile,
57create a dedicated `admin` profile:
58
59```sh
60alchemy profile create admin
61alchemy profile edit --profile admin --add Cloudflare
62```
63
64When 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]
76The credential stored under `--profile admin` can create new API
77tokens with arbitrary scopes. Treat it like root. Do **not** use it
78for everyday `alchemy deploy` runs against your application — keep
79your default profile on a narrowly scoped API Token (or OAuth) and
80only reach for `admin` when you're touching `stacks/github.ts`.
81:::
82
83You'll also be prompted for a GitHub credential. Pick `gh-cli` if
84you have the GitHub CLI installed, otherwise paste a Personal
85Access Token with the `repo` scope.
86
87## Scaffold the GitHub stack
88
89Create `stacks/github.ts` with an empty stack that loads both
90provider layers and reuses your remote state store. This is a
91one-shot stack you'll deploy from your laptop (under the `admin`
92profile) to provision the Cloudflare API token CI will use.
93
94```typescript
95// stacks/github.ts
96import * as Alchemy from "alchemy";
97import * as Cloudflare from "alchemy/Cloudflare";
98import * as GitHub from "alchemy/GitHub";
99import * as Effect from "effect/Effect";
100import * as Layer from "effect/Layer";
101import * as Redacted from "effect/Redacted";
102
103export 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
119profile you're deploying with — here, the `admin` profile — and the
120account ID comes along with them. You don't need to set a
121`CLOUDFLARE_ACCOUNT_ID` environment variable; your login already
122knows it.
123
124Note the double `yield*`: the service's value is itself an Effect,
125because credentials are cached and can refresh themselves (OAuth
126tokens expire). The first `yield*` gets the service, the second
127resolves the current credentials.
128
129Reusing the same `Cloudflare.state()` you set up in Part 1 means the
130token's ID is tracked alongside the rest of your infrastructure, so
131any future edit (rename, policy change) produces a clean diff.
132
133## Mint a scoped Cloudflare API token
134
135Add an `AccountApiToken` resource. Under the hood it calls
136`POST /accounts/{account_id}/tokens` and returns the freshly minted
137secret. Cloudflare only reveals that value once, so Alchemy stores it
138in 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
169The `permissionGroups` list scopes the token down to exactly what
170your app needs. If you don't use D1 or R2, drop those entries.
171
172## Push the token into GitHub
173
174Pipe `apiToken.value` straight into a `GitHub.Secret` so CI can read
175it 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
189Replace `your-org` and `your-repo` with your GitHub org and repo
190slug.
191
192## Expose the account ID to CI
193
194The workflow also needs `CLOUDFLARE_ACCOUNT_ID`. It isn't a secret in
195the cryptographic sense, but storing it as a GitHub Actions secret
196keeps 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
212treatment as the API token.
213
214## Deploy the GitHub stack
215
216Run it once from your laptop, under the `admin` profile:
217
218```sh
219alchemy deploy --config stacks/github.ts --profile admin
220```
221
222You'll see Alchemy plan three creates — the Cloudflare token and the
223two 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
227You only need to re-run this stack when you want to rotate the token
228or change its permissions.
229
230## Create the workflow
231
232Create `.github/workflows/deploy.yml`:
233
234```yaml
235name: Deploy
236on:
237 push:
238 branches: [main]
239 pull_request:
240 types: [opened, reopened, synchronize, closed]
241
242concurrency:
243 group: deploy-${{ github.ref }}
244 cancel-in-progress: false
245
246env:
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
252jobs:
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
302The cleanup job includes a safety check so `prod` can never be
303accidentally destroyed.
304:::
305
306## Add PR preview comments
307
308Post the live URL as a comment on every PR. Update `alchemy.run.ts`:
309
310```diff lang="typescript"
311// alchemy.run.ts
312import * as Alchemy from "alchemy";
313import * as Cloudflare from "alchemy/Cloudflare";
314+import * as GitHub from "alchemy/GitHub";
315+import * as Output from "alchemy/Output";
316import * as Effect from "effect/Effect";
317+import * as Layer from "effect/Layer";
318import { Bucket } from "./src/bucket.ts";
319import Worker from "./src/worker.ts";
320
321export 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
361The logical ID `"preview-comment"` stays the same across pushes, so
362Alchemy updates the existing comment instead of creating a new one.
363`GITHUB_TOKEN` is provided automatically by Actions and authorizes
364the comment.
365
366## The full picture
367
368Here's how everything fits together:
369
3701. 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.
3732. A developer pushes a branch and opens a PR.
3743. GitHub Actions checks out the code, installs deps, and runs
375 `alchemy deploy --stage pr-42` using the CI token.
3764. 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.
3795. The reviewer clicks the URL and tests the preview.
3806. The PR is merged — the cleanup job runs
381 `alchemy destroy --stage pr-42` and the preview infrastructure
382 disappears.
3837. The push to `main` deploys `--stage prod`.
384
385Each environment is fully isolated with its own bucket, worker, and
386state.
387
388## Optional: separate test and prod Cloudflare accounts
389
390If you want to keep preview environments on one Cloudflare account
391and production on another, deploy two tokens from the same
392`stacks/github.ts` and prefix the secrets accordingly.
393
394Your profile resolves a single account ID, so this is the one case
395where you pass the account IDs in explicitly — set
396`TEST_CLOUDFLARE_ACCOUNT_ID` and `PROD_CLOUDFLARE_ACCOUNT_ID` in
397your environment when deploying this stack.
398
399```typescript
400// stacks/github.ts
401import * as Alchemy from "alchemy";
402import * as Cloudflare from "alchemy/Cloudflare";
403import * as GitHub from "alchemy/GitHub";
404import * as Config from "effect/Config";
405import * as Effect from "effect/Effect";
406import * as Layer from "effect/Layer";
407import * as Redacted from "effect/Redacted";
408
409export 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
453In your workflow, choose which set of credentials to expose based
454on `STAGE`:
455
456```yaml
457env:
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
462Your `admin` profile needs API-token-write permission on **both**
463accounts. The simplest way is to log in with the Global API Key of
464a user that's a member of both.
465
466## Recap
467
468You 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
478In [Part 6](/cloudflare/tutorial/part-6), you'll ship the Worker's
479traces and logs to Axiom — with the datasets and ingest token
480declared 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