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
pnpm
ornpm
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. Thebinding
name 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
.env
file, 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 themain
branch. -
jobs: deploy: runs-on: ubuntu-latest
: Defines a job nameddeploy
running 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 optionalenv
section 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 bulk
command.
For running wrangler dev
locally:
-
Create a
.dev.vars
file in your project root. -
Add your local environment variables/secrets to this file in
KEY=VALUE
format:
# .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.vars
to your.gitignore
file.
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