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.
{
"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:
- When I open a pull request → Deploy to staging
- When I merge to main → Deploy to production
#Staging Deployment
This runs every time someone opens or updates a pull request:
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:
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 Name | What It's For |
|---|---|
CLOUDFLARE_API_TOKEN | Lets GitHub deploy to your Cloudflare account |
CONVEX_DEPLOY_KEY | Lets GitHub update your Convex database (if you use Convex) |
VITE_*_STAGING | Your staging environment URLs |
VITE_*_PRODUCTION | Your 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:
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:
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.
