Running Doota is mostly hands-off, but three things are on you: keeping the encryption keys safe, backing up all state together, and applying migrations carefully.
Key management
Doota holds two content secrets, both instance-level Worker secrets, never in D1:
MAIL_DEK— AES-256-GCM key that encrypts every subject and body in D1.MAIL_SEARCH_KEY— HMAC key for the blind-token search index.
Rotation
The encryption envelope is versioned and key-id-tagged
(v1.<keyId>.<iv>.<ciphertext>), so rotation is additive — a new key can
decrypt old rows by looking up the tagged key id, without a bulk re-encrypt.
Keep retired keys until every row they tagged has been re-encrypted or expired.
What “encrypted at rest” means here
Be precise about the posture — this is zero-access at rest, not end-to-end encryption. The operator can decrypt content by design (operator oversight is intended).
| Data | Location | At rest |
|---|---|---|
| Subject, body (text + HTML) | D1 (*_enc columns) |
Encrypted (AES-256-GCM, MAIL_DEK) |
| Routing + threading metadata (from, to, message-id, thread) | D1 | Cleartext by design — the hot path never decrypts |
| Search index tokens | D1 (message_fts) |
HMAC blind tokens (MAIL_SEARCH_KEY) — reveal no plaintext |
| Raw RFC 5322 message + attachments | R2 (MAIL_RAW) |
Cleartext — the canonical blob is stored as-is |
Backup and restore
State lives in three places that must be backed up together — a partial backup is not recoverable:
D1 (metadata + encrypted content)
Export the database:
wrangler d1 export doota --remote --output doota-backup.sqlR2 (raw messages, attachments, drafts)
Sync the bucket to durable storage with wrangler r2 object get /
rclone / the S3-compatible API. Everything under raw/<orgId>/… is the
canonical message store.
Secrets (MAIL_DEK, MAIL_SEARCH_KEY, BETTER_AUTH_SECRET)
Back these up out-of-band, in a password manager or vault. D1 + R2 without
MAIL_DEK cannot be read. If you let the deploy mint them, the values
live in the Alchemy state store in your Cloudflare account — the safest
posture is to provide your own values via the deploy env so a copy exists
in your vault from day one.
Restore is the reverse: recreate resources, import the D1 dump, restore R2 objects, and provide the same secrets in the deploy environment — the deploy binds them to all three Workers.
Running migrations
Migrations live in drizzle/. In production they apply automatically:
every deploy runs pending migrations before the Workers update — a brand-new
database gets the full schema on its first deploy. Tracking uses the standard
d1_migrations table, so the manual command stays equivalent if you ever
need it outside a deploy:
pnpm db:migrate:remote # wrangler d1 migrations apply doota --remote
pnpm db:migrate:local # local dev database (deploys don't touch it)Recovery hatches
If someone is locked out, in order of preference:
-
Backup codes — the lost-authenticator path, generated at TOTP enrollment.
-
In-app change-password dialog — self-service; needs an emailed code plus the current password.
-
Forgot password — sends a reset link to the verified external recovery address (never the Doota address).
-
CLI
reset-admin— the floor for a super-admin, with no email or network dependency:pnpm --filter doota reset-admin you@external-example.com 'new-password' --remote --clear-2fa--clear-2faresets the authenticator too. This always works as long as you hold deploy access.
Logs and observability
Set LOG_LEVEL (debug / info / warn / error) per Worker to control
mail-pipeline verbosity. Worker logs are enabled and persisted in the committed
wrangler.jsonc — tail them live with:
wrangler tail doota-mail-inbound
wrangler tail doota-mail-jobs