Guides

Deploying to Cloudflare with Alchemy

Learn how to deploy your Better-T-Stack app to Cloudflare Workers using Alchemy infrastructure-as-code

byOscar Gabriel·

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/env package

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 cloudflare

Understanding 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:

FrameworkAlchemy ResourceNotes
Next.jsNextjsUses OpenNext adapter
NuxtNuxtUses Nitro Cloudflare preset
SvelteKitSvelteKitUses Alchemy SvelteKit adapter
TanStack StartTanStackStartFull SSR support
React RouterReactRouterUses React Router Cloudflare adapter
TanStack RouterViteStatic site with assets
SolidJSViteStatic 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/.env

This 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 prod

Environment Variables:

# packages/infra/.env.prod
ALCHEMY_STAGE=prod

bun run deploy --env-file .env.prod

Default Stage Resolution:

  1. --stage CLI argument
  2. ALCHEMY_STAGE environment variable
  3. STAGE environment variable
  4. Current username ($USER)
  5. "dev" as fallback

Stage-Isolated State

Each stage stores its state in a separate directory:

web.json
server.json

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.ts file
  • 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 deploy

On first deploy, Alchemy will:

  1. Create Cloudflare Workers for your web and/or server
  2. Create D1 database (if configured)
  3. Apply database migrations
  4. Upload your code and assets
  5. Log the deployment URLs

Development Mode

# Runs web and/or server with Alchemy's local emulation
bun run dev

In 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 staging

This removes:

  • Cloudflare Workers
  • D1 databases (unless delete: false is 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:

  1. Commit state files to your repository (secrets are encrypted)
  2. 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.dev

Updating 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.dev

Replace your-subdomain with your actual Cloudflare Workers subdomain (found in the Cloudflare dashboard under Workers & Pages).

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"

  1. Check that the variable exists in the correct .env file
  2. Verify the dotenv config() call loads that file
  3. For secrets, use alchemy.secret.env.X not alchemy.env.X

"Secret cannot be decrypted" or "Password required"

  1. Ensure ALCHEMY_PASSWORD is set in your environment
  2. Use the same password that was used to encrypt the secrets
  3. Check that the password hasn't been changed since last deployment

"D1 migrations failed"

  1. Ensure migrations directory path is correct in alchemy.run.ts
  2. Run migrations locally first: bun run db:push
  3. Check migration files are valid SQL

"Worker size too large"

  1. Check your bundle size with bun run build
  2. Enable minification in your build config
  3. Review dependencies - some packages aren't edge-compatible

"CORS errors in browser"

  1. Verify CORS_ORIGIN matches your web Worker URL exactly
  2. Check that preflight requests are handled
  3. Ensure cookies have correct SameSite settings

On this page