Maybe you're a vibe coder, or you're a seasoned vet who's frustrated with Next and server components. Either way, I'll show you how to set up continuous deployment for a TanStack Start application to Cloudflare Workers using GitHub Actions (securely, of course).
Assumption:
- You have a TanStack Start project initialized.
- Package manager like
pnpmornpm
Prereqs:
-
Cloudflare Account: Grab an API token with the appropriate template.
-
GitHub Repository: Your TanStack Start project hosted on GitHub.
-
Node.js & pnpm: Installed locally.
-
Wrangler CLI: (Optional but recommended for local testing) Installed globally or as a dev dependency (
pnpm add -D wrangler). -
GitHub CLI (
gh): (Optional) Useful for managing secrets programmatically if you have a lot.
TanStack Start uses Vinxi, which builds upon Nitro. Ensure your build process is configured to output a Cloudflare Workers compatible format.
Check your app configuration (in app.config.ts) and ensure the Nitro preset targets Cloudflare Workers preset: 'cloudflare-module'.
How do we know this is an option? It's not in the Tanstack Start docs (currently), but you can just check the guts of the Vinxi deployment presets:
/**
* Not all the deployment presets are fully functional or tested.
* @see https://github.com/TanStack/router/pull/2002
*/
declare const vinxiDeploymentPresets: readonly ["alwaysdata", "aws-amplify", "aws-lambda", "azure", "azure-functions", "base-worker", "bun", "cleavr", "cli", "cloudflare", "cloudflare-module", "cloudflare-pages", "cloudflare-pages-static", ...];
type DeploymentPreset = (typeof vinxiDeploymentPresets)[number] | (string & {});Now, install the unenv package.
pnpm add unenv
The server property in app.config.ts should look like this:
import { cloudflare } from "unenv";
...
server: {
preset: "cloudflare-module",
unenv: cloudflare,
},- Verify that your build script (e.g.,
pnpm build) generates the worker entry point, typically at.output/server/index.mjs.
Create a wrangler.toml file in the root of your project. This file configures your Cloudflare Worker deployment.
# wrangler.toml
# Replace with your desired worker name (must be unique within your account)
name = "your-app-name"
# Entry point for your worker, generated by the build process
main = ".output/server/index.mjs"
# Enable Node.js compatibility APIs if your app or dependencies need them
compatibility_flags = ["nodejs_compat"]
# Use a recent compatibility date
compatibility_date = "2024-09-19" # Or a later date
# Example: Serve static assets built by Vinxi/Vite
# The binding name "ASSETS" must match how you reference it in your server code (if needed)
# Check Vinxi/Nitro Cloudflare preset docs for asset handling specifics.
# assets = { directory = "./.output/public/", binding = "ASSETS" }
# Example: Route traffic from your custom domain to this worker
# routes = [{ pattern = "your-domain.com", custom_domain = true }]
# Define bindings for Cloudflare resources (KV, R2, D1, etc.)
# These names must match the variable names used in your Worker code
[[kv_namespaces]]
binding = "YOUR_KV_BINDING_NAME" # e.g., SESSION_KV
id = "your_kv_namespace_id" # Find in Cloudflare Dashboard
[[r2_buckets]]
binding = "PROFILE_BUCKET" # e.g., USER_PROFILES_BUCKET
bucket_name = "user-profiles" # Your R2 bucket name
# Add other bindings (D1 databases, Queues, etc.) as needed
# [vars] section is NOT used for secrets during deployment with this GitHub Action setup.
# Secrets are injected securely via the workflow.
# Use a separate `.dev.vars` file for local development secrets.
[observability]
# Optional: Enable observability features if needed
enabled = true
-
name: Your Worker's name on Cloudflare. -
main: Path to the built server entry point. Adjust if your build output differs. -
compatibility_flags: Crucial if your code uses Node.js APIs (fs,path,crypto,http, etc.). -
compatibility_date: Use a recent date. -
assets: (Optional) Configure if/how static assets are served directly by the Worker. Consult Vinxi/Nitro documentation for the best approach with the Cloudflare preset. -
routes: (Optional) Define how traffic is routed to your worker (e.g., from a custom domain). -
Bindings (
[[kv_namespaces]],[[r2_buckets]], etc.): Define connections to other Cloudflare resources. Thebindingname is how you access the resource in your Worker code (e.g.,env.PROFILE_BUCKET).
Never commit secrets directly into your code or wrangler.toml. Use GitHub Actions secrets.
-
Go to your GitHub repository > Settings > Secrets and variables > Actions.
-
Click "New repository secret" for each secret your application needs at runtime and for deployment.
-
Required Deployment Secrets:
-
CLOUDFLARE_API_TOKEN: Generate a Cloudflare API token with "Edit Workers" permissions (My Profile > API Tokens > Create Token). -
CLOUDFLARE_ACCOUNT_ID: Find this on the right sidebar of your Cloudflare dashboard's Workers & Pages overview page.
- Application Secrets: Add secrets for all environment variables your deployed Worker needs (based on your
.envfile, excluding client-sideVITE_*variables unless also needed server-side):
-
DATABASE_URL -
(Add any others specific to your application)
Create a file named .github/workflows/deploy.yml. This workflow automates the build and deployment process when you push to the main branch.
name: Deploy to Cloudflare Workers
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build application for Cloudflare Workers
run: pnpm build
env:
VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }}
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
id: deploy
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
secrets: |
DATABASE_URL
... rest of secrets
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
... rest of env vars
Wtf is this?:
-
on: push: branches: [main]: Triggers the workflow on pushes to themainbranch. -
jobs: deploy: runs-on: ubuntu-latest: Defines a job nameddeployrunning on the latest Ubuntu runner. -
steps: The sequence of actions: -
Checkout code.
-
Set up pnpm and Node.js (with caching).
-
Install dependencies using
pnpm install --frozen-lockfile. -
Build the application using
pnpm build. (Note the optionalenvsection for build-time variables). -
Deploy using
cloudflare/wrangler-action@v3: -
apiToken,accountId: Provided from GitHub secrets. -
secrets:: A multiline list of secret names that the action should upload to Cloudflare Workers secrets. -
env:: Makes the corresponding GitHub secrets available as environment variables to the action itself, which is necessary for the underlyingwrangler secret bulkcommand.
For running wrangler dev locally:
-
Create a
.dev.varsfile in your project root. -
Add your local environment variables/secrets to this file in
KEY=VALUEformat:
# .dev.vars (Add this file to .gitignore!)
DATABASE_URL="your_local_or_dev_db_connection_string"
# ... add all other secrets/variables needed locally ...
- IMPORTANT: Add
.dev.varsto your.gitignorefile.
Wrangler will automatically load variables from .dev.vars when you run wrangler dev.
Ensure your .gitignore file includes:
# .gitignore
# Secrets and Local Overrides
.env
.dev.vars
# Build output
.output/
dist/
.vinxi/
# Dependencies
node_modules/
# Wrangler state
.wrangler/
Commit wrangler.toml, .github/workflows/deploy.yml, and your updated .gitignore. Push your changes to the main branch.
git add wrangler.toml .github/workflows/deploy.yml .gitignore CLOUDFLARE_DEPLOYMENT.md
git commit -m "feat: Configure Cloudflare Workers deployment via GitHub Actions"
git push origin main
Check the "Actions" tab in your GitHub repo
(Optional: Script to Set GitHub Secrets)
Setting secrets is a pain in the ass. Skip the manual work with the GitHub CLI (gh) to automate adding secrets from your local .env file to your GitHub repository. Run it from the scripts directory._
- Create
scripts/set-github-secrets.sh(ensure it's executable:chmod +x scripts/set-github-secrets.sh)
# scripts/set-github-secrets.sh
#!/bin/bash
# Reads secrets from ../.env and sets them in the current GitHub repo via gh cli.
# Prerequisites: gh cli installed and authenticated (`gh auth login`).
ENV_FILE="../.env"
REPO_OWNER_AND_NAME="$(gh repo view --json nameWithOwner -q .nameWithOwner)"
if [ -z "$REPO_OWNER_AND_NAME" ]; then
echo "Error: Could not automatically determine repository name."
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: .env file not found at $ENV_FILE"
exit 1
fi
echo "Setting secrets for repository: $REPO_OWNER_AND_NAME"
while IFS= read -r line || [ -n "$line" ]; do
line=$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -z "$line" ] || [[ "$line" =~ ^# ]]; then
continue
fi
if [[ "$line" =~ ^VITE_ ]]; then
echo "Skipping VITE_ variable: $line"
continue
fi
if [[ "$line" =~ ^([^=]+)=(.*) ]]; then
SECRET_NAME="${BASH_REMATCH[1]}"
SECRET_VALUE="${BASH_REMATCH[2]}"
SECRET_VALUE=$(echo "$SECRET_VALUE" | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")
echo "Setting secret: $SECRET_NAME ..."
if gh secret set "$SECRET_NAME" --repo "$REPO_OWNER_AND_NAME" -b"$SECRET_VALUE"; then
echo "Secret $SECRET_NAME set successfully."
else
echo "Error setting secret $SECRET_NAME."
fi
else
echo "Skipping malformed line: $line"
fi
done < "$ENV_FILE"
echo "Finished setting secrets."
- Run
./scripts/set-github-secrets.sh
How will this work with the new Vite config?