Skip to content

CI Workflows

Copy these GitHub Actions workflows into your repo's .github/workflows/ directory. Adjust branch names, env names, and secrets as needed.

Prerequisites

GitHub secrets / variables to configure:

Secret / VariableTypePurpose
CLOUDFLARE_API_TOKENSecretCF API token (scoped to your account)
CLOUDFLARE_ACCOUNT_IDVariableCF account ID
TAMER_SECRETS_KEY_devSecretVault master key for dev env
TAMER_SECRETS_KEY_prodSecretVault master key for prod env

Dev deploy (push to dev branch)

yaml
# .github/workflows/dev-deploy.yml
name: Dev Deploy

on:
  push:
    branches: [dev]

jobs:
  deploy:
    timeout-minutes: 10
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.10

      - run: bun install --no-save --linker hoisted --backend copyfile

      - name: Verify secrets
        run: bunx tamer secrets verify --env dev
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          TAMER_SECRETS_KEY_dev: ${{ secrets.TAMER_SECRETS_KEY_DEV }}

      - name: Apply
        run: bunx tamer apply --env dev
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          TAMER_SECRETS_KEY_dev: ${{ secrets.TAMER_SECRETS_KEY_DEV }}

      - name: Migrate
        run: bunx tamer migrate --env dev
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

      - name: Deploy
        run: bunx tamer deploy --env dev
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          TAMER_SECRETS_KEY_dev: ${{ secrets.TAMER_SECRETS_KEY_DEV }}
          DO_NOT_TRACK: "1"

Prod deploy (merge to main)

yaml
# .github/workflows/prod-deploy.yml
name: Prod Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    timeout-minutes: 10
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.10

      - run: bun install --no-save --linker hoisted --backend copyfile

      - name: Verify secrets
        run: bunx tamer secrets verify --env prod
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          TAMER_SECRETS_KEY_prod: ${{ secrets.TAMER_SECRETS_KEY_PROD }}

      - name: Apply
        run: bunx tamer apply --env prod
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          TAMER_SECRETS_KEY_prod: ${{ secrets.TAMER_SECRETS_KEY_PROD }}

      - name: Migrate
        run: bunx tamer migrate --env prod
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

      - name: Deploy
        run: bunx tamer deploy --env prod
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          TAMER_SECRETS_KEY_prod: ${{ secrets.TAMER_SECRETS_KEY_PROD }}
          DO_NOT_TRACK: "1"

PR preview deploy

yaml
# .github/workflows/pr-preview.yml
name: PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: pr-preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  preview:
    timeout-minutes: 15
    runs-on: ubuntu-latest
    if: github.event.pull_request.head.repo.full_name == github.repository
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.10

      - run: bun install --no-save --linker hoisted --backend copyfile

      - name: Configure env
        run: |
          echo "PR_ENV=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
          echo "TAMER_SECRETS_KEY_pr-${{ github.event.pull_request.number }}=${{ secrets.TAMER_SECRETS_KEY_DEV }}" >> $GITHUB_ENV

      - name: Copy secrets
        run: bunx tamer secrets copy --from dev --to ${{ env.PR_ENV }}
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

      - name: Apply
        run: bunx tamer apply --env ${{ env.PR_ENV }}
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

      - name: Deploy
        run: bunx tamer deploy --env ${{ env.PR_ENV }}
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          DO_NOT_TRACK: "1"

      - name: Comment preview URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `Preview deployed: https://${process.env.PR_ENV}.yourdomain.com`
            });
        env:
          PR_ENV: ${{ env.PR_ENV }}

PR cleanup (PR closed)

yaml
# .github/workflows/pr-cleanup.yml
name: PR Cleanup

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.10

      - run: bun install --no-save --linker hoisted --backend copyfile

      - name: Destroy ephemeral env
        run: |
          PR_ENV=pr-${{ github.event.pull_request.number }}
          bunx tamer destroy --env $PR_ENV --confirm-env $PR_ENV --wipe-metadata --force
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

Scheduled garbage collection

yaml
# .github/workflows/env-gc.yml
name: Env GC

on:
  schedule:
    - cron: '0 * * * *'  # hourly

jobs:
  gc:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.3.10

      - run: bun install --no-save --linker hoisted --backend copyfile

      - name: Garbage collect stale envs
        run: bunx tamer env gc --max-age 72h --force
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

Order matters

The correct CI command sequence for any env:

tamer secrets verify → tamer apply → tamer migrate → tamer deploy
  • verify fails if a declared secret is missing from the vault
  • apply creates Cloudflare resources + generates wrangler.json
  • migrate runs D1 migrations (needs resources to exist)
  • deploy builds SPA + deploys workers + pushes secrets + registers workflows

For PR previews, migrate is needed only if your stack declares stack-level D1 (e.g. the API worker's app-db); tenant shard groups are migrated during wfp tenant provision. Skip migrate for tenant-only stacks; otherwise run it on fresh pr-<n> envs too. For prod, always run all four.

Released under the Tamer Evaluation License.