February 4, 2026

Deploying TanStack Start to Cloudflare Workers

A practical guide to setting up staging and production environments for TanStack Start on Cloudflare Workers with automated deployments.

I've always been the Vercel guy. Push code, get a live site. No config needed.

But these past weeks, I deployed two TanStack Start apps to Cloudflare Workers. The initial setup took some figuring out, but once it's working—it just works.

Here's everything I learned.

#What I Built

My setup has two separate deployments:

  • Staging — A test version where I preview changes before they go live
  • Production — The real site that users see

I also set up automatic deployments. When I open a pull request, staging updates automatically. When I merge to main, production updates.

I was using Convex for my database and Better Auth for login, so I had to make sure all the URLs pointed to the right places in each environment.

#The Config File

Cloudflare uses a file called wrangler.jsonc to know how to deploy your app. Think of it as a settings file that tells Cloudflare what your app is called, where the files are, and what URLs to use.

wrangler.jsonc
jsonc
{
  "name": "my-app",
  "main": "./dist/_worker.js",
  "assets": {
    "directory": "./dist/assets"
  },
  "vars": {
    "VITE_CONVEX_URL": "https://dev-instance.convex.cloud",
    "VITE_SITE_URL": "http://localhost:3001"
  },
  "env": {
    "staging": {
      "name": "my-app-staging",
      "vars": {
        "VITE_CONVEX_URL": "https://dev-instance.convex.cloud",
        "VITE_SITE_URL": "https://my-app-staging.workers.dev"
      }
    },
    "production": {
      "name": "my-app-production",
      "vars": {
        "VITE_CONVEX_URL": "https://prod-instance.convex.cloud",
        "VITE_SITE_URL": "https://myapp.com"
      }
    }
  }
}

The vars section holds your environment variables—things like API URLs that change between staging and production.

#Automatic Deployments with GitHub Actions

GitHub Actions lets you run code automatically when certain things happen in your repo. I set up two workflows:

  1. When I open a pull request → Deploy to staging
  2. When I merge to main → Deploy to production

#Staging Deployment

This runs every time someone opens or updates a pull request:

.github/workflows/deploy-staging.yml
yaml
name: Deploy to Staging
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
 
      - name: Install dependencies
        run: bun install
 
      - name: Build the app
        run: bun run build
        env:
          CLOUDFLARE_ENV: staging
          VITE_CONVEX_URL: ${{ secrets.VITE_CONVEX_URL_STAGING }}
          VITE_SITE_URL: ${{ secrets.VITE_SITE_URL_STAGING }}
 
      - name: Deploy to Cloudflare
        run: bunx wrangler deploy --env staging
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
 
      - name: Post the staging link
        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: '🚀 Staging deployed: https://my-app-staging.workers.dev'
            })

The last step posts a comment on the PR with the staging link—super handy for reviewing changes.

#Production Deployment

This runs when code gets merged into the main branch:

.github/workflows/deploy-production.yml
yaml
name: Deploy to Production
 
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
 
      - name: Install dependencies
        run: bun install
 
      - name: Deploy database changes
        run: bunx convex deploy
        env:
          CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
 
      - name: Build the app
        run: bun run build
        env:
          CLOUDFLARE_ENV: production
          VITE_CONVEX_URL: ${{ secrets.VITE_CONVEX_URL_PRODUCTION }}
          VITE_SITE_URL: ${{ secrets.VITE_SITE_URL_PRODUCTION }}
 
      - name: Deploy to Cloudflare
        run: bunx wrangler deploy --env production
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

#Secrets You'll Need

These are stored in your GitHub repo settings under Secrets and Variables → Actions:

Secret NameWhat It's For
CLOUDFLARE_API_TOKENLets GitHub deploy to your Cloudflare account
CONVEX_DEPLOY_KEYLets GitHub update your Convex database (if you use Convex)
VITE_*_STAGINGYour staging environment URLs
VITE_*_PRODUCTIONYour production environment URLs

#Protecting Your Staging Site

You probably don't want random people finding your staging site. Cloudflare Access adds a login screen that only lets your team in.

Go to your Worker settings

Open your Worker in Cloudflare Dashboard, click the Settings tab, then find Domains & Routes.

Turn on Cloudflare Access

Find the workers.dev row, click the three-dot menu (⋯), and toggle on "Cloudflare Access".

Set who can access

Click "Manage Cloudflare Access" to choose who's allowed in—your team members, specific email addresses, etc.

Save the credentials

A dialog will show two values: an Issuer (your team's domain) and an Audience (a unique ID). Copy both—you'll need them if you want to verify visitors in your code.

Now your staging site requires login. Only people you've approved can see it.

#Verifying Visitors in Your Code (Optional)

If you turned on Cloudflare Access, every request to your app includes a special token proving the visitor logged in. You can check this token to make sure it's valid.

I used a library called jose for this:

src/start.ts
ts
import { createRemoteJWKSSet, jwtVerify } from "jose";
 
const JWKS = createRemoteJWKSSet(
  new URL(`https://${CF_ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs`)
);
 
export async function validateVisitor(request: Request) {
  const token = request.headers.get("CF_ACCESS_JWT_ASSERTION");
 
  if (!token) {
    return { valid: false, error: "No token found" };
  }
 
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: `https://${CF_ACCESS_TEAM_DOMAIN}`,
      audience: CF_ACCESS_AUD,
    });
    return { valid: true, payload };
  } catch (error) {
    return { valid: false, error: "Invalid token" };
  }
}

The CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are those values you copied earlier.

#Setting Up Login (Better Auth + Convex)

For user authentication, I used Better Auth with Convex as the database. Here's the basic setup:

convex/auth.ts
ts
import { betterAuth } from "better-auth";
import { convex } from "@convex-dev/better-auth";
 
export const auth = betterAuth({
  database: convex(/* your convex client */),
  session: {
    expiresIn: 60 * 60 * 24 * 7, // Users stay logged in for 7 days
    updateAge: 60 * 60 * 24,     // Refresh the session daily
  },
  emailOTP: {
    otpLength: 6,
    expiresIn: 300, // Code expires in 5 minutes
    sendOTP: async ({ email, otp }) => {
      // Send the code via email (Resend, SendGrid, etc.)
    },
  },
  trustedOrigins: getTrustedOrigins(),
});
 
function getTrustedOrigins() {
  const origins = process.env.BETTER_AUTH_TRUSTED_ORIGINS;
  return origins ? origins.split(",") : [];
}

#What I Learned

Double-check your environment variables

Unlike Vercel, you need to set variables in multiple places: the wrangler config AND your GitHub workflow. Miss one and things break.

Keep production data separate

Use a different database for production. Staging can share your development database, but production needs its own.

Protect staging early

Setting up Cloudflare Access takes 5 minutes and prevents accidental exposure. It's free for small teams.

Test locally first

Run wrangler dev to test your app in a Cloudflare-like environment before pushing. Catches issues early.

#Final Thoughts

The initial setup takes more work than Vercel. No way around it.

But once it's running:

  • Deployments finish in seconds
  • Your app runs at the edge, close to your users
  • Costs are predictable and usually lower
  • You control everything

For TanStack Start, the Cloudflare integration is solid. Most of my time went into environment setup and automated deployments—not fighting the framework.

Would I do it again? Yes. Am I a Cloudflare expert now? Let's say I can help you debug your deployment.

Reach out if you're stuck.