Skip to content

Part 6: Stages and CI

Terminal window
pnpm alchemy deploy --stage staging

The MyEnv environment’s name includes the stage, so staging gets its own environment, app, images and volumes, and changing the image in one stage never reaches another. Your own stage is untouched.

src/Sandbox.ts
export const MyEnv = Modal.Environment("MyEnv", {});
export const MyEnv = Modal.Environment("MyEnv", {
maxConcurrentTasks: 20,
budget: 50,
});

budget is a monthly limit in US dollars for the environment. Modal stops starting containers when it’s reached.

CI runs start from an empty disk, so store state where every run can read it:

alchemy.run.ts
{
providers: Layer.mergeAll(Cloudflare.providers(), Modal.providers()),
state: Alchemy.localState(),
state: Cloudflare.state(),
},

Modal has no OIDC federation, so CI uses a token. Create one for a workspace you use only for CI, and add MODAL_TOKEN_ID and MODAL_TOKEN_SECRET to the repository’s secrets.

.github/workflows/deploy.yml
name: Deploy
on:
pull_request:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'prod' }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun alchemy deploy --stage "$STAGE" --yes

Each pull request deploys its own stage, in its own Modal environment, and so builds its own base: a pull request that changes Warm rebuilds it for that stage only.

.github/workflows/cleanup.yml
name: Cleanup
on:
pull_request:
types: [closed]
jobs:
destroy:
runs-on: ubuntu-latest
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun alchemy destroy --stage "pr-${{ github.event.number }}" --yes

Destroying the stage removes its environment, with its images and the volumes your Worker created in it.