Runbook

Deploying it

What standing Keelpost up in a Cloudflare account actually involves. We have not published the repository, so this is not a sequence you can run today. It is published because “runs in your own account” is a claim that ought to arrive with its bill of work attached, and because the bill is larger than the phrase suggests.

Before you start

  • Workers Paid. Not optional and not a preference. Sending to arbitrary recipients, Queues past the free daily allowance and SQLite-backed Durable Objects each require it.
  • Two domains, or a domain and a subdomain. Staging and production may not share a mailbox domain — Email Routing is configured per zone, and the deployment gate rejects a configuration where the two environments name the same one.
  • Cloudflare nameservers, as a hard requirement. Email Routing cannot accept mail for a zone whose DNS Cloudflare does not serve. There is no workaround, and this is the constraint that decides whether Keelpost can run for you at all.
  • A pinned Node and npm. The repository fixes both, and the install fails closed on an unsupported runtime rather than producing a subtly different build.

If the apex you want already carries an MX record you cannot displace, use a subdomain as the mailbox domain instead of fighting it. That decision belongs here, before anything else is named after the domain.

What gets created

Per remote environment, and there are two of them:

  • One Worker, one D1 database for identity and access control, and two R2 buckets — the second exists so that a remote development session cannot select the bucket holding live attachments, settings documents and outbound payloads.
  • Six queues, each with its own dead-letter queue. Twelve per environment, so twenty-four queue resources across staging and production before a single message moves. Five carry work the application publishes; the sixth is consumer-only, because its producer is Cloudflare’s own delivery-event subscription.
  • Two Durable Object namespaces, created by the first deploy rather than by a command.
  • One Email Routing zone, one onboarded sending domain, and one delivery-event subscription.
  • Six secrets, none of which is inherited between environments.

Add roughly a dozen more queue resources if you also want a remote development environment; ordinary local development runs on a local emulator and needs none of them.

There is no bulk command for the queues. This is the whole of it, and its shape is the point:

# Six queues and their six dead-letter queues, per environment.
for env in staging production; do
  for q in outbound-email auto-draft auth-email \
           maintenance mailbox-erasure email-lifecycle; do
    npx wrangler queues create "keelpost-$env-$q"
    npx wrangler queues create "keelpost-$env-$q-dlq"
  done
done

The order, and why it is that order

Two facts decide the sequence, and both are cheap to learn the expensive way.

The delivery-event subscription cannot exist until its queue exists, and the subscription’s identifier is then a Worker variable. So the Worker is uploaded twice: once to create the Durable Object namespaces and prove the topology, and again once the identifier is filled in.

An Email Routing rule cannot target a Worker that does not exist. So inbound mail is wired after the first deploy, not before — which means there is a window in which the Worker is live and no mail reaches it, and that is correct rather than broken.

  1. Create the databases and buckets

    Two databases and four buckets. Record each identifier; they are deployment inventory, not secrets, and they belong in version control. The two environments may never share one.

  2. Create the queues and their dead-letter queues

    There is no bulk command; it is a loop. The dead-letter wiring, retry counts and batch sizes are declared in configuration and applied at deploy time, but the dead-letter queues themselves are ordinary queues that must already exist or the deploy fails.

  3. Enable Email Routing on the inbound zone, and verify the DNS

    An unpropagated or overridden MX record is the single most common reason a deploy succeeds and no mail ever arrives. Read the records back rather than trusting that they were added.

  4. Onboard the sending domain

    Verify that DKIM, SPF and DMARC all resolve before the first real send. A cold domain that fails any of them lands in spam, and that failure is invisible from inside the product. Turn the sending activity-log preview off before live traffic: it retains full sent content for about a week, in a place no application erasure receipt covers.

  5. Create the delivery-event subscription, and record its identifier

    Dashboard only — see below. Staging and production need separate subscriptions pointed at separate queues; sharing one makes production delivery receipts reconcile against staging state.

  6. Fill in the four lifecycle identities

    Account, subscription, zone and domain. These are not decoration: every inbound delivery event is compared against all four before it reaches any state machine, and a wrong value does not degrade gracefully — it rejects every receipt you are sent.

  7. Decide the public origin, once

    The canonical origin is the OAuth issuer and the origin every browser session and every MCP client is bound to. Changing it after people exist invalidates sessions and breaks registered clients. Decide before the first deploy, not after.

  8. Install the six secrets

    Per environment, independently generated, none of them shared between trust domains and none of them in version control.

  9. Migrate, then deploy

    Schema before the code that reads it. The deploy script re-runs the topology gate, requires a clean committed tree, rebuilds, re-verifies the generated configuration and tags the deployment with the commit. A bare deploy command skips all of that.

  10. Wire inbound mail, then confirm it

    Point the zone at the Worker now that it exists. The Worker remains authoritative about which recipients it accepts: it canonicalises the envelope recipient, not the MIME header, and drops anything outside its configured list. A routing rule broader than that list is safe; one narrower than it silently loses mail.

  11. Bootstrap the first account

    One authorised call, once per environment, creating the first owner, workspace and mailbox. It is leased, resumable and idempotent, and a database row rather than the secret is what permanently prevents a second one. A wrong or short secret answers 404 rather than 401, so the endpoint does not advertise itself.

That is roughly fifty discrete operator actions with a laptop, a dozen of them in a dashboard rather than a terminal. Reducing that number to something a person can do in an afternoon is real work that has not been done, and it is on the roadmap rather than in this page.

The steps no command performs

Two, and pretending otherwise would waste an afternoon of yours.

The delivery-event subscription. The CLI has a subscription command, but its list of event sources does not include email sending, and the public API schema does not list it either. It is created in the dashboard, scoped to the sending domain, with all six delivery events selected, and its identifier read back afterwards.

Zone identifiers. No CLI command prints one. The dashboard overview, or a REST call.

Everything the CLI can tell you, read back rather than assume. Two of these are the difference between a working deployment and one where mail silently never arrives:

npx wrangler email routing dns get example.com   # MX, SPF
npx wrangler email sending dns get example.com   # DKIM, SPF, DMARC
npx wrangler email routing rules list example.com
npx wrangler queues subscription list keelpost-staging-email-lifecycle

The secret you cannot lose

Five of the six secrets rotate. One does not.

The mailbox suppression key is a lifetime trust root. The control-plane database stores only its fingerprint, and provisioning fails closed if that fingerprint ever stops matching. Losing it makes every mailbox permanently unprovisionable, and there is no support path that recovers it, because a support path that recovered it would be the vulnerability.

Vault it and back it up before you continue past the secrets step. Not afterwards.

Proving it

A green deploy proves nothing about mail. Each of the following has failed in practice with every API call returning success.

  • Send a message to the mailbox from outside. It must appear. If it does not, the cause is MX propagation, a rule on the wrong zone, or the Worker on a different hostname — in that order of likelihood.
  • Send one out of the product and watch it reach a real inbox at a real provider, not a spam folder, with authentication passing.
  • Confirm a delivery event arrives and the state in the interface moves off queued. If it does not, one of the four lifecycle identities is wrong and the consumer is rejecting every event.
  • Break a stage on purpose and watch the retry and the dead-letter queue. That pipeline has never run against a real provider.

What this cannot give you

Proof that any of it works. The underlying runbook was written against a pinned toolchain, and every command in it was verified to exist and to accept the flags shown. Not one was run against a Cloudflare account. Nothing in this repository has been deployed, so the first person to execute this sequence should expect to correct it, and a wrong command in a runbook costs an hour that nobody has yet paid.

Join the waitlist

If you would run this in your own Cloudflare account, that is the case we are building for. Sign-ups are closed while we build; leave an address and we will write once, when they open.

We store the address you type, plus a hashed form of your IP address so the form can be rate-limited. One email when sign-ups open, and nothing after that. We do not share the list, and there is no analytics or third-party script on this site.

Next: what the deployment is protecting, and what has actually been exercised.