Deploying to Cloudflare with Alchemy
Learn how to deploy your Better-T-Stack app to Cloudflare Workers using Alchemy infrastructure-as-code
Overview
This guide explains how Better-T-Stack uses Alchemy to deploy your applications to Cloudflare Workers. You'll learn:
- What Cloudflare Workers and Alchemy are
- How to deploy web apps, server apps, or both
- How environment variables and secrets are managed
- How to work with D1 databases
- How to manage multiple stages (dev, prod, staging, etc)
- How type-safe bindings work with the
packages/envpackage
What is Cloudflare Workers?
Cloudflare Workers is a serverless platform that runs your code on Cloudflare's edge network across 300+ data centers worldwide. Unlike traditional serverless (AWS Lambda, Google Cloud Functions), Workers use V8 isolates instead of containers. This architecture provides near-zero cold starts, global distribution, native TypeScript type definitions generated by workerd, and full-stack framework support for React Router, TanStack Start, SvelteKit, and more.
Workers integrate seamlessly with Cloudflare's developer platform, including D1 databases, R2 object storage, KV stores, Durable Objects, etc; all accessible via typed bindings, all with very generous free tiers.
What is Alchemy?
Alchemy is an Infrastructure-as-Code (IaC) library. Unlike Terraform or Pulumi, Alchemy is pure TypeScript, resource-based, AI-friendly, and runs anywhere JS runs. You define what you want via normal async functions and Alchemy handles the creation, updating, and deletion of everything for you.
When you scaffold a project with Cloudflare deployment enabled, Better-T-Stack generates an alchemy.run.ts file that defines your entire infrastructure as code.
Enabling Cloudflare Deployment
When creating a project:
# Combined deployment (web + server)
bun create bts@latest my-app \
--frontend tanstack-router \
--backend hono \
--runtime workers \
--web-deploy cloudflare \
--server-deploy cloudflare
# Web-only (e.g., with Convex backend)
bun create bts@latest my-app \
--frontend tanstack-start \
--backend convex \
--web-deploy cloudflare
# Server-only
bun create bts@latest my-app \
--frontend none \
--backend hono \
--runtime workers \
--server-deploy cloudflareUnderstanding alchemy.run.ts
The alchemy.run.ts file is the heart of your deployment configuration. Here's a simplified example for a combined web + server deployment:
// packages/infra/alchemy.run.ts
import alchemy from "alchemy";
import { TanStackStart } from "alchemy/cloudflare";
import { Worker } from "alchemy/cloudflare";
import { D1Database } from "alchemy/cloudflare";
import { config } from "dotenv";
// Load environment variables from multiple .env files
config({ path: "./.env" });
config({ path: "../../apps/web/.env" });
config({ path: "../../apps/server/.env" });
// Initialize the Alchemy app
const app = await alchemy("my-app");
// Create D1 database (if using D1)
const db = await D1Database("database", {
migrationsDir: "../../packages/db/src/migrations",
});
// Deploy web frontend
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
VITE_SERVER_URL: alchemy.env.VITE_SERVER_URL!,
},
});
// Deploy server backend
export const server = await Worker("server", {
cwd: "../../apps/server",
entrypoint: "src/index.ts",
compatibility: "node",
bindings: {
DB: db,
CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
DATABASE_URL: alchemy.secret.env.DATABASE_URL!,
},
dev: {
port: 3000,
},
});
// Log deployment URLs
console.log(`Web -> ${web.url}`);
console.log(`Server -> ${server.url}`);
// Finalize (triggers cleanup of orphaned resources)
await app.finalize();Framework-Specific Deployments
Alchemy provides optimized deployment resources for each frontend framework:
| Framework | Alchemy Resource | Notes |
|---|---|---|
| Next.js | Nextjs | Uses OpenNext adapter |
| Nuxt | Nuxt | Uses Nitro Cloudflare preset |
| SvelteKit | SvelteKit | Uses Alchemy SvelteKit adapter |
| TanStack Start | TanStackStart | Full SSR support |
| React Router | ReactRouter | Uses React Router Cloudflare adapter |
| TanStack Router | Vite | Static site with assets |
| SolidJS | Vite | Static site with assets |
Environment Variables and Secrets
Loading Environment Variables
The generated alchemy.run.ts loads environment variables using dotenv:
import { config } from "dotenv";
// Load from multiple locations (order matters - later files override)
config({ path: "./.env" }); // packages/infra/.env
config({ path: "../../apps/web/.env" }); // apps/web/.env
config({ path: "../../apps/server/.env" }); // apps/server/.envThis allows you to:
- Keep shared variables in
packages/infra/.env - Keep web-specific variables in
apps/web/.env - Keep server-specific variables in
apps/server/.env
alchemy.env vs alchemy.secret.env
Alchemy provides two ways to access environment variables for bindings:
Public Variables
Use alchemy.env for non-sensitive configuration values. These are stored as plaintext in Alchemy's state files and are visible in logs:
bindings: {
CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
VITE_SERVER_URL: alchemy.env.VITE_SERVER_URL!,
STAGE: alchemy.env.STAGE!,
VERSION: "1.0.0",
}Use for:
- URLs and endpoints
- Feature flags
- Stage/environment identifiers
- Public configuration
Encrypted Secrets
Use alchemy.secret.env for sensitive values. These are encrypted in Alchemy's state files using AES-256-GCM:
bindings: {
BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
DATABASE_URL: alchemy.secret.env.DATABASE_URL!,
API_KEY: alchemy.secret.env.API_KEY!,
STRIPE_SECRET_KEY: alchemy.secret.env.STRIPE_SECRET_KEY!,
}Use for:
- API keys and tokens
- Database credentials
- Auth secrets
- Any sensitive data
Alchemy Password
Alchemy uses a password to encrypt and decrypt secrets. After creating a project, make sure to update the password variable in packages/infra/.env.
For CI/CD (GitHub Actions):
env:
ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }}Generate a strong password: openssl rand -base64 32
Without ALCHEMY_PASSWORD, any operation involving secrets will fail. Store this password securely and never commit it to source control.
Note that for apps using Convex as their backend, you should instead set your secrets directly in the Convex dashboard manually or with convex env set from packages/backend.
Multi-Stage Deployments
Alchemy supports deploying to multiple stages (environments) like development, production, staging, and so on. Each stage has isolated state and resources.
CLI Argument:
# Development (default)
bun run deploy
# Staging
bun run deploy --stage staging
# Production
bun run deploy --stage prodEnvironment Variables:
# packages/infra/.env.prod
ALCHEMY_STAGE=prod
bun run deploy --env-file .env.prodDefault Stage Resolution:
--stageCLI argumentALCHEMY_STAGEenvironment variableSTAGEenvironment variable- Current username (
$USER) "dev"as fallback
Stage-Isolated State
Each stage stores its state in a separate directory:
This ensures complete isolation between environments.
Stage-Based Resource Naming
Use app.stage to create unique resource names per environment:
const app = await alchemy("my-app");
// Resources include stage in their names
export const server = await Worker("server", {
name: `${app.name}-${app.stage}-server`, // e.g., "my-app-prod-server"
// ...
});
export const db = await D1Database("database", {
name: `${app.name}-${app.stage}-db`, // e.g., "my-app-dev-db"
// ...
});Environment-Specific Configuration
const stage = process.env.STAGE || "dev";
const app = await alchemy("my-app", { stage });
// Stage-specific settings
const isProd = app.stage === "prod";
export const server = await Worker("server", {
// Production gets custom domain, others get workers.dev URLs
url: !isProd,
domains: isProd ? ["api.myapp.com"] : undefined,
bindings: {
// Different URLs per environment
CORS_ORIGIN: isProd
? "https://myapp.com"
: `https://${app.stage}.myapp.com`,
},
});Type-Safe Bindings
How Bindings Work
When you define bindings in alchemy.run.ts, they become available in your Worker code at runtime. Alchemy provides type inference so you get full TypeScript support.
The env.d.ts Pattern
Better-T-Stack generates a packages/env/env.d.ts file that connects your Alchemy bindings to TypeScript:
import { type server } from "@my-app/infra/alchemy.run";
// Infer types from the Worker's bindings
export type CloudflareEnv = typeof server.Env;
declare global {
type Env = CloudflareEnv;
}
declare module "cloudflare:workers" {
namespace Cloudflare {
export interface Env extends CloudflareEnv {}
}
}This enables type-safe access to bindings in your server code.
Accessing Bindings in Code
With Hono:
import { Hono } from "hono";
import { env } from "cloudflare:workers";
// Access bindings via cloudflare:workers module
const app = new Hono()
.get("/users", async (c) => {
// Type-safe access to bindings
const db = drizzle(env.DB);
const users = await db.select().from(usersTable);
return c.json(users);
})
.get("/config", (c) => {
// Access env vars and secrets
return c.json({
corsOrigin: env.CORS_ORIGIN,
stage: env.STAGE,
});
});With Request Handler:
import type { server } from "@my-app/infra/alchemy.run";
export default {
async fetch(request: Request, env: typeof server.Env) {
// Type-safe access to all bindings
const value = await env.KV.get("key");
const apiKey = env.API_KEY;
return new Response(`Value: ${value}`);
}
};Integration with packages/env
Better-T-Stack uses the packages/env package for type-safe environment variables. The setup differs between Cloudflare Workers and traditional runtimes.
For Cloudflare Workers (server.ts)
When deploying to Cloudflare, server environment variables come from Worker bindings, not process.env:
// packages/env/src/server.ts (Cloudflare Workers)
/// <reference path="../env.d.ts" />
// Re-export env from cloudflare:workers module
// Types are defined in env.d.ts based on your alchemy.run.ts bindings
export { env } from "cloudflare:workers";This means:
- No t3-env validation for server env (bindings are already type-safe)
- Types come from Alchemy via the
env.d.tsfile - Runtime values are injected by Cloudflare Workers
For traditional backend runtimes, server environment variables come from process.env and do make use of t3-env validation.
For Web/Client (web.ts)
Client-side environment variables always use t3-env (Cloudflare or not):
// packages/env/src/web.ts
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
clientPrefix: "VITE_",
client: {
VITE_SERVER_URL: z.url(),
},
runtimeEnv: import.meta.env,
emptyStringAsUndefined: true,
});Local Resources
During development, Alchemy emulates your local environment using Miniflare. Running bun run dev creates a local SQLite databases that mimic your resources' real production behavior, so you can develop without deploying.
You can find these emulated resources in .alchemy/miniflare/v3/.
Deployment Commands
Root-Level Commands
Better-T-Stack adds these scripts to your root package.json:
{
"scripts": {
"dev": "...",
"deploy": "turbo -F @my-app/infra deploy",
"destroy": "turbo -F @my-app/infra destroy"
}
}Deploy Your Application
# Deploy to default stage (your username or "dev")
bun run deploy
# Deploy to a specific stage
bun run deploy --stage prod
# Or from the infra package directly
cd packages/infra && bun run deployOn first deploy, Alchemy will:
- Create Cloudflare Workers for your web and/or server
- Create D1 database (if configured)
- Apply database migrations
- Upload your code and assets
- Log the deployment URLs
Development Mode
# Runs web and/or server with Alchemy's local emulation
bun run devIn dev mode, Alchemy:
- Emulates D1 locally using Miniflare
- Provides local URLs for testing
- Hot-reloads on changes
Destroy Resources
# Tear down all deployed resources for current stage
bun run destroy
# Destroy a specific stage
bun run destroy --stage stagingThis removes:
- Cloudflare Workers
- D1 databases (unless
delete: falseis set) - All associated bindings
Warning: destroy permanently deletes your deployed resources. Database data will be lost unless you've configured delete: false or exported backups.
Continous Integration
Important: By default, Alchemy uses local file-based state storage. This can cause issues in CI/CD where the filesystem is ephemeral.
For CI/CD, you should either:
- Commit state files to your repository (secrets are encrypted)
- Use a remote state store via the CloudflareStateStore resource
See the alchemy docs to configure a state store and set up a CI/CD pipeline.
Cross-Domain Considerations
When web and server are deployed as separate Workers, they have different domains:
Web: https://my-app-web.your-subdomain.workers.dev
Server: https://my-app-server.your-subdomain.workers.devUpdating Environment Variables for Production
Before deploying, update your environment variables to use your production Worker URLs:
# apps/web/.env
VITE_SERVER_URL=https://my-app-server.your-subdomain.workers.dev
# apps/server/.env
CORS_ORIGIN=https://my-app-web.your-subdomain.workers.dev
BETTER_AUTH_URL=https://my-app-server.your-subdomain.workers.devReplace your-subdomain with your actual Cloudflare Workers subdomain (found in the Cloudflare dashboard under Workers & Pages).
Cookie Configuration for Auth
When using Better-Auth with separate web and server Workers, cookies need special configuration to work across subdomains. In packages/auth/src/auth.ts, configure these settings:
// packages/auth/src/auth.ts
export const auth = betterAuth({
// ... other config
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: ".workers.dev", // Shared domain for cookies
},
},
});The generated auth configuration includes these settings commented out. Uncomment them and replace the domain with your actual workers subdomain (e.g., .your-subdomain.workers.dev) when deploying to production.
Pay careful attention to CORS settings when moving between stages. A configuration that works locally may fail in production (and vice versa) if CORS origins or cookie domains don't match your actual deployment URLs.
Troubleshooting
"Environment variable X is undefined"
- Check that the variable exists in the correct
.envfile - Verify the dotenv
config()call loads that file - For secrets, use
alchemy.secret.env.Xnotalchemy.env.X
"Secret cannot be decrypted" or "Password required"
- Ensure
ALCHEMY_PASSWORDis set in your environment - Use the same password that was used to encrypt the secrets
- Check that the password hasn't been changed since last deployment
"D1 migrations failed"
- Ensure migrations directory path is correct in
alchemy.run.ts - Run migrations locally first:
bun run db:push - Check migration files are valid SQL
"Worker size too large"
- Check your bundle size with
bun run build - Enable minification in your build config
- Review dependencies - some packages aren't edge-compatible
"CORS errors in browser"
- Verify
CORS_ORIGINmatches your web Worker URL exactly - Check that preflight requests are handled
- Ensure cookies have correct
SameSitesettings