Skip to content

Edge ​

Implementation details for the Cloudflare Workers deployment. Read the Architecture Overview first for the mental model.

Workers Configuration ​

Each worker has its own wrangler.jsonc in its workspace directory:

WorkerConfignodejs_compatStatic assetsService bindings
webapps/web/wrangler.jsoncNoMarketing pagesAPP_SERVICE, API_SERVICE
appapps/app/wrangler.jsoncNoSPA bundle–
apiapps/api/wrangler.jsoncYes––

The API worker enables nodejs_compat for packages that depend on Node.js built-ins (e.g. postgres, crypto). The web and app workers don't need it – they only serve static assets and proxy requests.

Service Bindings ​

Service bindings are non-inheritable in Wrangler – the top-level declaration only applies to production. Each environment must redeclare its bindings with the correct worker names.

jsonc
// apps/web/wrangler.jsonc
{
  // Production (top-level)
  "services": [
    { "binding": "APP_SERVICE", "service": "example-app" },
    { "binding": "API_SERVICE", "service": "example-api" },
  ],

  "env": {
    "staging": {
      "services": [
        { "binding": "APP_SERVICE", "service": "example-app-staging" },
        { "binding": "API_SERVICE", "service": "example-api-staging" },
      ],
    },
  },
}

Worker naming convention: <project>-<worker>-<env>. Production omits the environment suffix.

EnvironmentWebAppAPI
Productionexample-webexample-appexample-api
Stagingexample-web-stagingexample-app-stagingexample-api-staging

Hyperdrive ​

Cloudflare Hyperdrive provides connection pooling between Workers and Neon PostgreSQL. The API worker declares two bindings per environment:

BindingCachingPurpose
HYPERDRIVE_CACHEDEnabledRead-heavy queries
HYPERDRIVE_UNCACHEDDisabledWrites and consistency-sensitive reads
jsonc
// apps/api/wrangler.jsonc
"hyperdrive": [
  { "binding": "HYPERDRIVE_CACHED", "id": "your-hyperdrive-cached-id-here" },
  { "binding": "HYPERDRIVE_UNCACHED", "id": "your-hyperdrive-uncached-id-here" }
]

Each environment has its own Hyperdrive IDs pointing to the corresponding Neon database branch.

The connection code in apps/api/lib/db.ts:

ts
import { schema } from "@repo/db";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

export function createDb(db: Hyperdrive) {
  const client = postgres(db.connectionString, {
    max: 1, // Two clients per request share the connection budget
    connect_timeout: 10,
    idle_timeout: 20,
    max_lifetime: 60 * 30,
    transform: { undefined: null },
    onnotice: () => {}, // Suppress PostgreSQL NOTICE messages
  });
  return drizzle(client, { schema, casing: "snake_case" });
}

Key settings: max: 1 per client, because every request builds two of them and Workers caps concurrent external connections. Prepared statements are left enabled – Hyperdrive only caches queries it sees prepared, so turning them off would cost the cache and add a round-trip. That is also why the origin must be an unpooled host: a transaction-mode pooler in front of Postgres breaks prepared statements.

Static Assets ​

Web Worker ​

The web worker serves marketing pages from apps/web/dist/. The run_worker_first setting forces specific paths through the worker script before falling back to static assets:

jsonc
// apps/web/wrangler.jsonc
"assets": {
  "directory": "./dist",
  "binding": "ASSETS",
  "run_worker_first": ["/"]
}

This is required for the / route where the worker checks the auth hint cookie to decide between the marketing page and the app dashboard. All other paths either match explicit worker routes (/api/*, /login*) or fall through to static assets.

App Worker ​

The app worker is a pure static asset worker with SPA fallback – no custom worker script:

jsonc
// apps/app/wrangler.jsonc
"assets": {
  "directory": "./dist",
  "not_found_handling": "single-page-application"
}

not_found_handling: "single-page-application" returns index.html for any path that doesn't match a static file, enabling TanStack Router's client-side routing.

The web worker's / route uses the auth hint cookie to choose between two upstream workers:

ts
// apps/web/worker.ts
app.on(["GET", "HEAD"], "/", async (c) => {
  const hasAuthHint =
    getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1";

  const upstream = await (hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS).fetch(
    c.req.raw,
  );

  // Prevent caching – response varies by auth state
  const headers = new Headers(upstream.headers);
  headers.set("Cache-Control", "private, no-store");
  headers.set("Vary", "Cookie");

  return new Response(upstream.body, {
    status: upstream.status,
    statusText: upstream.statusText,
    headers,
  });
});

The Cache-Control: private, no-store and Vary: Cookie headers prevent CDN and browser caches from serving the wrong version (marketing page to a logged-in user or vice versa). See ADR-001 for the full decision record.

Infrastructure ​

Terraform provisions what the workers consume. Wrangler owns the workers themselves – names, code, routes, custom domains and bindings. Nothing is configured by both tools, so the two can never disagree. See ADR-002.

infra/
├── modules/
│   └── cloudflare/    # Hyperdrive pair, optional R2 bucket
└── envs/              # One root = one HCP Terraform workspace = one state
    ├── staging/
    └── production/

Each environment gets a cached and an uncached Hyperdrive configuration:

hcl
module "edge" {
  source = "../../modules/cloudflare"

  account_id   = var.cloudflare_account_id
  project_slug = var.project_slug
  environment  = "staging"       # hard-coded: the directory already decided
  database_url = var.database_url
}

Applying it outputs the two IDs, which you paste into the matching environment block of apps/api/wrangler.jsonc. Worker names come from wrangler.jsonc alone: the top-level config deploys production, and --env <name> appends -<name>, which is what service bindings resolve against.

Local Development ​

bun dev starts three local development servers:

ServiceRuntimePortNotes
appVite5173Main development entry point
webAstro4321Marketing site
apiBun8787Hono server; the app proxies /api/* here

The deployed service-binding topology is not reproduced locally. Vite proxies /api/* to the Bun server, while apps/api/dev.ts uses Wrangler's getPlatformProxy() only to emulate the two Hyperdrive bindings, which it resolves from the CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_* variables – not from DATABASE_URL, which belongs to the Drizzle tooling in db/.

TIP

Email templates must be built before starting the API dev server. The bun dev script handles this automatically by running bun email:build first.