Skip to content

tutorials

Deploy your Tinbox email stack from GitHub Actions

August 3, 2026

#tinbox#github-actions#ci-cd#cloudflare#wrangler
Deploy your Tinbox email stack from GitHub Actions
Visual: Sable Wynn on okslop

Running wrangler deploy from your laptop is fine for one inbox. The moment you run email for several clients, or you want every push to ship the same way twice, manual deploys turn into a source of drift and 2 a.m. mistakes. This walks the full CI deploy by hand, then shows the Tinbox commands that make it a few lines.

A Tinbox email stack is a single Cloudflare Worker plus its D1 database, R2 bucket, KV namespace, and queues. Deploying it is just wrangler deploy with the schema migrated first. That is easy to do once interactively. The problem is doing it repeatably: across a team, across environments, and (for an agency) across a dozen client tenants that each live on their own domain. GitHub Actions is the obvious place to put that, and the whole thing is more boring than it looks. Here is the complete setup, including the failure modes that trip people up.

How do I deploy a Cloudflare email stack from GitHub Actions?

Store a scoped Cloudflare API token and your account ID as repository secrets, then write a workflow that installs dependencies, runs migrations, and calls wrangler deploy. Wrangler reads CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID straight from the environment, so there is no interactive login in CI. Everything else is ordinary Node CI.

The minimal workflow is short:

# .github/workflows/deploy.yml
name: Deploy email stack
on:
  push:
    branches: [main]

# Never run two deploys against the same Worker at once.
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Apply D1 migrations
        run: pnpm tinbox migrate

      - name: Deploy the Worker
        run: pnpm wrangler deploy

That is the whole happy path. The two interesting parts are the token (next section) and the ordering of migrate before deploy, which matters more than it looks.

What Cloudflare API token scopes does the deploy need?

A deploy token needs edit access to every resource the Worker touches: Workers Scripts, Workers Routes (for the custom domain route), D1, R2, Workers KV Storage, and Queues. Create it in the Cloudflare dashboard under My Profile or Account, API Tokens, with a custom token. Scope it to the one account you are deploying into.

Concretely, the permissions are:

Account → Workers Scripts          → Edit
Account → Workers Routes           → Edit
Account → D1                       → Edit
Account → Workers R2 Storage       → Edit
Account → Workers KV Storage       → Edit
Account → Queues                   → Edit

Put the token in CLOUDFLARE_API_TOKEN and the account ID in CLOUDFLARE_ACCOUNT_ID. The account ID is on the right side of any zone's overview page in the dashboard, or run wrangler whoami locally once.

If the token is missing a scope, the failure is specific and points at the resource. A token without D1 edit, for example, fails at the migration step:

✘ [ERROR] A request to the Cloudflare API failed.
  Authentication error [code: 10000]
  You do not have permission to access D1 on this account.

The fix is to add the missing permission to the token (or make a new one) rather than to retry. Tokens are silently cached in the runner environment for the job, so a re-run with the same token will fail the same way.

One more honest note: scope the token narrowly and rotate it. A deploy token that can edit Workers, D1, R2, KV, and Queues across the account is a real credential. If you run client tenants, consider one token per account rather than one token that can touch everything.

Should I run database migrations in the same job as the deploy?

Yes. Run tinbox migrate immediately before wrangler deploy, in the same job, so the schema is in place when the new Worker version starts serving. Tinbox migrations are applied to remote D1 over the Cloudflare API and are forward-only, so the order is migrate, then deploy:

      - name: Apply D1 migrations
        run: pnpm tinbox migrate

      - name: Deploy the Worker
        run: pnpm wrangler deploy

The reason for this ordering is the usual one for any app that ships schema and code together: a newly deployed Worker that expects a column the database does not have yet will throw on the first request. By migrating first, the new code never sees an old schema. Because migrations are forward-only and additive in practice, the previous Worker version keeps working against the migrated database during the brief overlap.

The thing to protect against is two deploys racing on the same database. That is what the concurrency block at the top of the workflow is for: cancel-in-progress: false queues a second run rather than cancelling the first, so migrations apply one at a time. Do not skip it. Two tinbox migrate calls hitting the same D1 database simultaneously is exactly the kind of bug that only shows up in production.

Can tinbox setup run unattended in CI?

Partly, and the distinction matters. tinbox setup is idempotent and creates the D1 database, R2 bucket, KV namespace, and queues without prompting. It also auto-generates internal secrets like BETTER_AUTH_SECRET and ASSET_SIGNING_KEY. What it does prompt for, interactively, is external keys it cannot generate: ANTHROPIC_API_KEY (or OPENAI_API_KEY) for the AI assistant, and GITHUB_CLIENT_SECRET for GitHub OAuth. CI cannot answer a prompt, so a naive tinbox setup --deploy in Actions will hang or fail at that step.

The clean split is: provision and set external secrets once, out of band; let CI only migrate and deploy from then on. Do the one-time part either from a laptop or from a separate, manually-triggered workflow:

# .github/workflows/provision.yml: run once per tenant, manually.
name: Provision (one-time)
on:
  workflow_dispatch: {}

jobs:
  provision:
    runs-on: ubuntu-latest
    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile

      # Create D1/R2/KV/queues and inject ids into wrangler.jsonc.
      # --skip-secrets avoids the interactive prompts for external keys.
      - run: pnpm tinbox setup --skip-secrets

      # Set the external keys non-interactively, from Actions secrets.
      - name: Push external secrets
        run: |
          echo "${{ secrets.ANTHROPIC_API_KEY }}" | pnpm wrangler secret put ANTHROPIC_API_KEY
          echo "${{ secrets.GITHUB_CLIENT_SECRET }}" | pnpm wrangler secret put GITHUB_CLIENT_SECRET

wrangler secret put reads the value from stdin, which is what makes it scriptable. Note the ordering wrinkle that tinbox setup itself warns about: secrets can only be pushed after the Worker exists. If you provision without ever deploying and then try to set secrets, you get:

! BETTER_AUTH_SECRET: worker not deployed yet.
  Run `pnpm tinbox:deploy` once, then `tinbox setup` again to push secrets.

So the safe one-time sequence is provision, deploy once, then push secrets, then re-run setup to confirm. After that, your deploy.yml only ever needs to migrate and deploy, which is the boring repeatable part you want on every push.

How does an agency deploy many client email stacks from one repo?

Use a GitHub Actions matrix over tenant directories, because one Tinbox deploy serves one brand/domain. There is no multi-tenant flag that fans out a single deploy to many domains: that is designed but not built. The shipped model is one deploy per tenant, each with its own resources named tinbox-<client>-*, and a matrix is the natural way to drive several from one workflow.

Lay the repo out with one directory per client, each a scaffolded Tinbox project (its own wrangler.jsonc and tinbox.config.ts):

tenants/
  acme/          # tinbox-acme,       tinbox-acme-db,   acme.com
  globex/        # tinbox-globex,     tinbox-globex-db, globex.io
  initech/       # tinbox-initech-*,  initech.dev

Then the deploy workflow loops over them:

# .github/workflows/deploy.yml
name: Deploy all tenants
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    strategy:
      # Keep going if one client's deploy fails; do not block the others.
      fail-fast: false
      matrix:
        tenant: [acme, globex, initech]
    # Serialize per tenant so two runs never migrate the same DB at once.
    concurrency:
      group: deploy-${{ matrix.tenant }}
      cancel-in-progress: false
    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    defaults:
      run:
        working-directory: tenants/${{ matrix.tenant }}
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }

      - run: pnpm install --frozen-lockfile

      - name: Migrate ${{ matrix.tenant }}
        run: pnpm tinbox migrate

      - name: Deploy ${{ matrix.tenant }}
        run: pnpm wrangler deploy

Two details make this work in practice. fail-fast: false stops one client's broken deploy from aborting the rest. The per-tenant concurrency group keeps each client's migrations serialized without serializing the whole fleet, so all three deploy in parallel but no single database gets two concurrent migrations. If your clients live on different Cloudflare accounts, set the token and account ID per matrix leg (a small lookup keyed on matrix.tenant) instead of one shared pair.

This is the agency pattern in full: one repo, one workflow, every client shipped on every push, each on their own domain and their own data. For the branding and per-client theming side of this, see white-labeling the inbox and sender per client, and for the DNS and routing per tenant, see wiring a custom domain per tenant. Before you point CI at a live tenant, it is worth walking the Cloudflare Workers email checklist for production so the first automated deploy ships a stack that actually delivers.

What does the same thing look like without GitHub Actions?

The CI workflow above is just wrapping commands you can run anywhere — the Tinbox CLI is identical on a laptop, in Actions, or any other runner, because it is a thin layer over wrangler and the Cloudflare API. The local equivalent of the whole pipeline is one-time tinbox setup --deploy (interactive, answers the external-key prompts), then tinbox migrate && wrangler deploy per release. That laptop flow, including the initial scaffold, is the npx tinbox init walkthrough.

The reason it drops cleanly into a matrix is that tinbox setup reads resource names from wrangler.jsonc: the command does not need to know which client it is deploying, the working directory tells it.

Doing this the hard way (and why CI is still worth it)

If you assembled this pipeline against a managed email SaaS instead, CI would look different and, honestly, thinner. With Resend, SendGrid, or Mailgun, there is nothing to deploy: the sender runs on the vendor's infrastructure, so your CI just runs tests and maybe syncs templates through an API. That is genuinely less to maintain. You do not write a deploy workflow because there is no deploy.

The trade is that the vendor owns the runtime, the data, and the bill. You cannot deploy a fix to how mail is handled because you do not run the code; you file a support ticket. You cannot point CI at "your" infrastructure because it is not yours. For a lot of teams that is the right call, and if you are weighing it, the honest cost comparison is in how per-email pricing adds up at volume.

Tinbox sits on the other side of that trade on purpose: the stack runs on your own Cloudflare account, so deploying it is your CI's job, and that is the point. The upside is that the same wrangler deploy that ships your app ships your email stack, the data stays in your D1 and R2, and an agency can fan out to every client from one matrix with no per-tenant SaaS contract. The cost is the workflow above, which you write once. For where this fits in the larger picture, start at the Tinbox overview.

Deploy this yourself: npx tinbox init <domain>, then drop the deploy.yml from this post into .github/workflows/. The full CLI reference and the bundled CI examples live in the docs.

Frequently asked questions

How do I deploy a Cloudflare email stack from GitHub Actions?

Store a scoped CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as repository secrets, check out the repo, install dependencies, apply database migrations with tinbox migrate, then run wrangler deploy. Wrangler reads the token from the environment, so no interactive login is needed. The Tinbox CLI commands (tinbox migrate, tinbox setup) are thin wrappers around wrangler and Cloudflare's APIs, so they work the same in CI as on a laptop.

What Cloudflare API token scopes does the deploy need?

A token used for deploying a Tinbox Worker needs Workers Scripts edit, Workers Routes edit (for the custom domain), D1 edit (for migrations), R2 edit, Workers KV Storage edit, and Queues edit. First-time provisioning with tinbox setup also creates those resources, so the same scopes cover both. Account ID goes in CLOUDFLARE_ACCOUNT_ID. Scope the token to one account and rotate it like any other deploy credential.

Can tinbox setup run unattended in CI?

Partly. tinbox setup is idempotent and creates D1, R2, KV, and queues without prompting, and it auto-generates internal secrets like BETTER_AUTH_SECRET. But it prompts interactively for external keys such as ANTHROPIC_API_KEY or GITHUB_CLIENT_SECRET. In CI, set those once out of band (wrangler secret put, run manually or in a separate gated job) so the unattended pipeline only provisions, migrates, and deploys.

How does an agency deploy many client email stacks from one repo?

Because one Tinbox deploy serves one brand/domain, give each client its own project directory (or its own wrangler config) and drive them with a GitHub Actions matrix. Each matrix leg checks out the same repo, cds into that client's directory, and runs migrate then deploy against that tenant's resources, named tinbox-<client>-*. Multi-domain-per-instance is designed but not built, so the model today is one deploy per tenant.

Should I run database migrations in the same job as the deploy?

Run tinbox migrate before wrangler deploy in the same job, so the schema is in place when the new Worker version goes live. Migrations are applied to remote D1 over the Cloudflare API and are forward-only. Keep the deploy job serialized per tenant (GitHub concurrency groups) so two runs cannot apply migrations to the same database at once.