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
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.SETUP_TOKEN— any string you choose, min 8 chars. After deploying you’ll open/setupwith it to create the super-admin. The deploy requires it.- The app’s Cloudflare API token —
APP_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 printssecretSourcesshowing 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 32MAIL_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
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 infoFor 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.