Mackie
note

PR previews with Alchemy, Cloudflare, and GitHub Actions

Want pull-request previews without making production deployment a separate system? Define one Alchemy stack, give every PR a stage, and let GitHub Actions select the stage.

Setup

  • Add the Alchemy and Cloudflare secrets to GitHub Actions.
  • Replace example.com/* with the production route.
  • Keep nub run build and nub run astro dev aligned with the app’s normal build and dev commands.

1. Stage the resource

apps/web/alchemy.run.ts
Effect.gen(function* () {
const stage = yield* Alchemy.Stage;
const website = yield* Cloudflare.Website.StaticSite("website", {
name: `website-${stage}`,
command: "nub run build",
outdir: "dist",
dev: { command: "nub run astro dev" },
routes: stage === "prod" ? [{ pattern: "example.com/*" }] : undefined,
});
return { url: website.url };
});

Use pr-<number> for previews and prod for production. For previews, comment website.url back to the pull request.

apps/web/alchemy.run.ts
Effect.gen(function* () {
if (stage.startsWith("pr-") && process.env.PULL_REQUEST) {
yield* GitHub.Comment("preview-comment", {
owner: "octo-org",
repository: "site",
issueNumber: Number(process.env.PULL_REQUEST),
body: `## Preview deployed
**Website:** ${website.url}`,
});
}
});
.github/workflows/verify.yml
name: Verify
on:
pull_request:
types: [opened, reopened, synchronize]
workflow_call:
concurrency:
group: verify-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
astro-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: nubjs/setup-nub@v0
with:
nub-version: 0.5.0
- run: |
nub pm shim
echo "$HOME/.nub/shims" >> "$GITHUB_PATH"
- run: nub install --frozen-lockfile
- run: nub --cwd apps/web exec astro check
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: nubjs/setup-nub@v0
with:
nub-version: 0.5.0
- run: |
nub pm shim
echo "$HOME/.nub/shims" >> "$GITHUB_PATH"
- run: nub install --frozen-lockfile
- run: nub --cwd apps/web exec astro sync
- run: nub exec tsc --project apps/web/tsconfig.json --noEmit

workflow_call lets production reuse the same gate. The concurrency key drops outdated PR checks.

.github/workflows/deploy-preview.yml
on:
pull_request:
types: [opened, reopened, synchronize, closed]
concurrency:
group: deploy-preview-${{ github.event.pull_request.number }}
cancel-in-progress: false
env:
STAGE: pr-${{ github.event.number }}
ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }}
ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
.github/workflows/deploy-preview.yml
jobs:
deploy-preview:
if: >-
github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
steps:
# repeat the checkout, nub setup, shim, and install steps from verify.yml
- run: nub run deploy -- --yes --stage ${{ env.STAGE }}
cleanup-preview:
if: >-
github.event.action == 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
steps:
# repeat the checkout, nub setup, shim, and install steps from verify.yml
- run: nub run destroy -- --yes --stage ${{ env.STAGE }}

The repository guard keeps deployment secrets out of forked PRs. Leave preview runs uncancelled: a deploy and destroy for the same stage must not overlap.

.github/workflows/deploy-prod.yml
on:
push:
branches: [main]
env:
STAGE: prod
jobs:
verify:
uses: ./.github/workflows/verify.yml
deploy-prod:
needs: verify
steps:
# checkout, set up nub, install from the lockfile
- run: nub run deploy -- --yes --stage ${{ env.STAGE }}

That is the complete shape: one stack, named stages, disposable PR environments, and production gated by the same checks.