Skip to content
Knowledge sections

Optional providers and env

How env-gated providers work — exact variables, where to set them, and how to verify activation.

envoptional-providersconfiguration
On this page

Every third-party provider in the starter is env-gated. The app boots with zero provider secrets; each provider activates when its environment variables are set. This guide covers each provider: what it does, the exact variables, where the values come from, and a concrete way to confirm activation.

How env validation works

packages/env/src/server.ts is the single source of truth. The ServerEnv type declares every server variable: two required (BETTER_AUTH_SECRET, BETTER_AUTH_URL) and the rest optional. Optional vars simply stay unset until you configure their provider; nothing fails and no surface degrades beyond that provider being off. In production, auditRequiredEnv rejects placeholder or too-short auth secrets before any request is served (see the config gate in apps/web/src/start.ts).

Where values go

RuntimeWhere to set variables
Web dev server (bun run dev from the repo root)Root .env, copied from .env.example
API worker under Wrangler (bun --cwd apps/api dev)apps/api/.dev.vars; apps/api/wrangler.jsonc ships WORKERS_AI_ENABLED = "false" and CLOUDFLARE_EMAIL_FROM = "noreply@example.com" as dev defaults
Deployed Workers (bun run deploy)The deploy shell environment; alchemy.run.ts forwards the optional keys to all three Workers and wraps secret-shaped values in Redacted

Cloudflare Email

Transactional email through Cloudflare Email Service. React Email templates live in packages/email; the wired consumers are the workspace invitation send path (apps/web/src/lib/server/invitations.ts) and the account lifecycle emails: password reset, verification, and the one-time codes (apps/web/src/lib/server/auth-emails.ts, the AuthEmailSender adapter for Better Auth). The API Worker has no email consumer: it cannot persist an invitation, so it serves no invitation endpoint.

VariableValue
CLOUDFLARE_EMAIL_FROMA sender address on a domain verified in Cloudflare Email Routing

Verify the domain under your Cloudflare zone → Email Routing. At deploy time, alchemy.run.ts provisions the EMAIL SendEmail binding only when this variable is set and restricts sending to it via allowedSenderAddresses. At runtime, selectEmailDispatcherLayer in packages/email uses the Cloudflare dispatcher when the binding and sender exist; otherwise the log dispatcher renders templates and logs email.dispatched with mode: "log"; no real send. The API worker also accepts EMAIL_FROM_ADDRESS as a back-compat alias, but CLOUDFLARE_EMAIL_FROM is the name used end to end.

Unconfigured: invitations and lifecycle emails render to the log instead of sending; everything else works.

Verify: set the variable in .env, restart bun run dev, and send an invitation on a deployed stack, confirming delivery.mode is cloudflare-email in the response.

Turnstile

CAPTCHA protection for the sign-up form. The widget renders on /sign-up and its token is verified server-side against Cloudflare's siteverify endpoint before Better Auth accepts the registration.

VariableValue
TURNSTILE_SITE_KEYWidget site key (safe to expose)
TURNSTILE_SECRET_KEYWidget secret key (server-side only)

Create a widget under the Cloudflare dashboard → Turnstile, with your deployment's hostname in the allowed domains.

How it works: when both variables are set, /sign-up renders the challenge widget (server-rendered site key via getTurnstileSiteKey) and the auth route verifies each sign-up's token (x-turnstile-token header → TurnstileVerifier.verify, in packages/capabilities/src/governance/turnstile-verification.ts). A failed challenge returns captcha_rejected; if Cloudflare itself is unreachable, sign-up fails closed with captcha_unavailable.

Unconfigured: no widget renders, no verification runs; the sign-up form behaves exactly as it does without Turnstile.

Verify: set both variables in .env, restart, and load /sign-up: the challenge widget appears and a valid solution lets sign-up through; submitting without one shows the "bot check" error.

Social sign-in

GitHub and Google sign-in over Better Auth's socialProviders. See social sign-in for the provider setup, the exact OAuth callback URLs, and the account-linking rules.

VariableValue
GITHUB_CLIENT_IDGitHub OAuth app client ID
GITHUB_CLIENT_SECRETGitHub OAuth app client secret
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle OAuth client secret

Unconfigured: the provider buttons do not render and the provider is absent from the Better Auth config; the Local Auth Path is the whole auth surface.

Assistant (Workers AI or OpenAI-compatible)

The starter assistant answers POST /assistant/answer on the API worker and, in the web app, at /workspaces/$slug/assistant: a chat page that calls AssistantService directly through a TanStack Start server function (no REST round-trip). Both surfaces pick the provider with selectAssistantLayer from packages/ai, in order:

  1. Workers AI, when WORKERS_AI_ENABLED is true and the AI binding exists (default model @cf/meta/llama-3.1-8b-instruct). The binding is declared in apps/{api,web}/wrangler.jsonc and attached to the deployed workers by alchemy.run.ts; no API key involved.
  2. OpenAI-compatible, when OPENAI_API_KEY is set (defaults: base URL https://api.openai.com/v1, model gpt-4o-mini). Any OpenAI-compatible endpoint works via OPENAI_BASE_URL.
  3. A mock provider otherwise.
VariableValue
WORKERS_AI_ENABLEDtrue to use the Workers AI binding
OPENAI_API_KEYAPI key for an OpenAI-compatible provider
OPENAI_BASE_URLOptional override, defaults to https://api.openai.com/v1
OPENAI_MODEL_IDOptional override, defaults to gpt-4o-mini

Unconfigured: the API endpoint still answers from the mock provider (provider: "mock", assistantConfigured: false), and the web assistant page hides its form behind honest copy naming the variables that enable a real provider (assistantConfigured-equivalent comes from the loader's configured field).

Verify: on a stack where the API worker has the variables, call the endpoint with a workspace API Token:

curl -s https://<api-host>/assistant/answer \
  -H 'authorization: Bearer <api-token>' \
  -H 'content-type: application/json' \
  -d '{"workspaceSlug":"<slug>","question":"Hello"}'

The response's provider field reads workers-ai or openai-compatible and assistantConfigured is true.

Workspace export (R2 + Queue)

Owners can export a workspace as a gzipped JSON document from workspace settings (ADR 0055). The archive is built by the background worker and stored in an R2 bucket for seven days; downloads go through a signed link on the API worker.

VariableValue
WORKSPACE_EXPORT_BUCKETThe R2 bucket name, e.g. b2b-saas-starter-workspace-exports
API_PUBLIC_URLThe deployed API worker's origin, so download links from the web app point at it

At deploy time, alchemy.run.ts provisions the bucket (with a seven-day lifecycle rule), the export queue, and their bindings on all three workers only when WORKSPACE_EXPORT_BUCKET is set. Locally, the generated wrangler.jsonc files always carry both bindings (miniflare simulates R2 and Queues), and the web dev server uses the in-memory Seed adapter, which builds the same archive without either.

Unconfigured: the settings card explains that exports need a bucket and shows no button. Nothing else degrades.

Verify: on a deployed stack, request an export as an owner, wait for the "Workspace export ready" notification, and follow the download link; the audit page shows workspace.export_requested, .export_completed, and .export_downloaded.

Stripe billing

Billing is implemented and provider-light: plan display on /workspaces/$slug/billing and entitlement gating (Starter caps API tokens at 2 and webhook endpoints at 1) work with every variable below unset. Setting them activates the checkout handoff and the inbound webhook route; see the Stripe doc in the Integration surfaces section for the full flow.

VariableValue
STRIPE_SECRET_KEYStripe Dashboard → Developers → API keys
STRIPE_WEBHOOK_SECRETSigning secret shown when you create the Stripe webhook endpoint
STRIPE_PRICE_ID_TEAMPrice id of the Team plan's recurring price

Verify activation: with all three set, the Billing page shows an "Upgrade to Team" button for owners, and POST /webhooks/stripe on the background Worker accepts signed events (it answers 503 while STRIPE_WEBHOOK_SECRET is unset).

Sentry and PostHog (observability)

Error monitoring and product analytics, wired through the official SDKs (@sentry/cloudflare / @sentry/react and posthog-node / posthog-js) as env-gated providers: set the variables below and both activate across web, api, and background plus the web browser bundle; leave them unset and nothing changes. See the Sentry and PostHog guide for what each activates. Wide-event telemetry still goes through OTEL_EXPORTER_OTLP_ENDPOINT.

VariableValue
SENTRY_DSNSentry project → Settings → Client Keys (DSN)
POSTHOG_KEYPostHog project settings → project API key
POSTHOG_HOSTOptional; .env.example defaults to https://us.i.posthog.com

Adding a provider variable

Declare the variable in ServerEnv. Everything else derives from the type: alchemy.run.ts builds its forwarding env from the two optional key lists (secret or plain) in packages/env/src/server.ts, and workers read the var off their own env binding. apps/web/src/worker-env.d.ts derives its string vars from it. The full secret matrix lives in ARCHITECTURE.md.