Skip to content

Configuration

Step 1: decide your instance's settings — domains, setup token, API token — before you deploy.

Updated View as Markdown

Start here. Before deploying, decide what your instance looks like: which domain it serves, how you’ll unlock the setup wizard, and whether it can onboard mail domains. Configuration lives in the deploy environment and the deploy applies it: values you put in infra/.env (deploying from your machine) or GitHub secrets/variables (deploying from CI) become Worker secrets and vars on every deploy — nothing is set through wrangler or the dashboard.

Decide these three things

  1. ORIGINS — the URL(s) your instance will serve on, full URLs with protocol, comma-separated, first entry canonical (e.g. ORIGINS=https://mail.acme.com). Each hostname is attached as a Cloudflare custom domain, so its zone must already exist in your account. Skip it to start on workers.dev and add your domain later.
  2. SETUP_TOKEN — any string you choose, min 8 chars. After deploying you’ll open /setup with it to create the super-admin. The deploy requires it.
  3. The app’s Cloudflare API tokenAPP_CLOUDFLARE_ACCOUNT_ID + APP_CLOUDFLARE_API_TOKEN, needed the moment you onboard a mail domain. Creating it has its own page: Cloudflare API token.

Everything else can wait: the core secrets mint themselves, and every value below can be added or changed later with a redeploy.

If you’re deploying from your machine, put the values in infra/.env now (cp infra/.env.example infra/.env); for GitHub deploys you’ll enter them as repository secrets/variables during Deploy.

How values behave

Two behaviors to know:

  • Minted if unset — the core secrets (MAIL_DEK, MAIL_SEARCH_KEY, BETTER_AUTH_SECRET, the web-push keypair) are generated on the first deploy and persisted in the stack’s state store; every later deploy reuses the same values. Provide your own and it wins. The deploy prints secretSources showing env vs state per secret — and because one value is bound to all three Workers, the keys can never drift between them.
  • Re-read every deploy — everything else (ORIGINS, SETUP_TOKEN, APP_CLOUDFLARE_*, …) rebinds from the current environment on each deploy: change it → redeploy; clear it → the binding is removed.

Web app (doota)

Variable Required Secret What it does
ORIGINS The app’s serving origins: comma-separated full URLs with protocol. First entry is the canonical app URL (absolute links, auth fallback); every entry is attached to the web Worker as a Cloudflare custom domain (zones must exist) and trusted by auth as an allowed host. Unset in production → workers.dev, URL derived automatically. Locally it must include the dev origin — a mismatch 404s the auth routes.
BETTER_AUTH_SECRET minted Signs sessions and tokens. 32+ chars, high entropy.
MAIL_DEK minted AES-256-GCM data-encryption key for subjects and bodies in D1. Base64, decodes to exactly 32 bytes.
MAIL_SEARCH_KEY minted HMAC key for blind-token search. Instance secret, distinct from MAIL_DEK.
APP_CLOUDFLARE_ACCOUNT_ID Cloudflare account the domain-onboarding flow provisions in.
APP_CLOUDFLARE_API_TOKEN Scoped runtime API token for onboarding — a different token than the deploy one. Never the Global API Key. See token scopes.
MAIL_IN_WORKER_NAME auto Name of the deployed inbound Worker that the catch-all routing rule targets. The deploy injects it from the deployed Worker itself — you never set it.
CRON_SECRET Bearer secret the cron trigger presents to POST /api/cron for maintenance sweeps.
SETUP_TOKEN One-time token (min 8 chars) that gates the /setup genesis wizard — the deploy fails without it.
LOG_LEVEL Mail-pipeline log level: debug | info | warn | error. Defaults to info.
UNSUBSCRIBE_URL Path/query for the {{ unsubscribe_url }} template variable (default /unsubscribe). The host is taken from the request origin, never from here, so one deployment serving multiple domains links each recipient to the correct host. Set an absolute URL to point at an external unsubscribe system.
DATABASE_URL dev only Local Drizzle target (file:local.db). Production uses the D1 binding.

Mail Workers (doota-mail-inbound, doota-mail-jobs)

Both mail Workers touch encrypted content, so both need the content keys:

Variable Required Secret What it does
MAIL_DEK minted Same key as the web app — identical across all three Workers.
MAIL_SEARCH_KEY minted Same key as the web app.
CRON_SECRET ✓ (jobs) doota-mail-jobs presents this when calling the web app’s /api/cron.
LOG_LEVEL Per-Worker log level, as above.

Generating values

You usually don’t: the deploy mints the core secrets (see above). Generate manually only when you want to provide your own — e.g. to keep a copy in your password manager, or for local dev .env:

# Any 32-byte, base64 secret — usable for BETTER_AUTH_SECRET, MAIL_DEK,
# MAIL_SEARCH_KEY, CRON_SECRET, SETUP_TOKEN.
openssl rand -base64 32

MAIL_DEK is validated on use: it must decode to exactly 32 bytes, or the Worker throws MAIL_DEK must decode to 32 bytes. base64url and stray whitespace are tolerated, so any of the common generators work.

Local .env example

.envsh
DATABASE_URL=file:local.db
ORIGINS="http://localhost:5173"
BETTER_AUTH_SECRET="<32+ chars>"
MAIL_DEK="<base64 32 bytes>"
MAIL_SEARCH_KEY="<base64 32 bytes>"
SETUP_TOKEN="dev-setup-token"  # required in dev; used at /setup
APP_CLOUDFLARE_ACCOUNT_ID=""
APP_CLOUDFLARE_API_TOKEN=""
MAIL_IN_WORKER_NAME="doota-mail-inbound"
CRON_SECRET=""
LOG_LEVEL=""                   # defaults to info

For the mail Workers in local dev, put MAIL_DEK / MAIL_SEARCH_KEY / LOG_LEVEL in each Worker’s .dev.vars.

Next

Create the app’s Cloudflare API token (or defer it), then deploy.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close