Mail hq

Documentation

Deploy your own

Run Mail HQ in your own Cloudflare account, on your own domains, behind Cloudflare Access.

Deploy to Cloudflare

The button deploys the app, not your mail setup

You still need to configure your domains, Cloudflare Access, Email Routing, and Email Sending. The app stays locked (HTTP 401) until Access is configured. Do not disable authentication to get past it.

Before you start

  • A Cloudflare account with R2 enabled
  • A domain (or dedicated subdomain) on Cloudflare that you want to host with Cloudflare Email Routing
  • Cloudflare Zero Trust, for the Access application that protects the dashboard
  • Node.js 22.13 or newer if you build or run it yourself (Node.js 24 LTS is recommended)

Cloudflare resource usage and sending availability depend on your account and plan.

Deploy

Click Deploy to Cloudflare. Cloudflare clones the repository and provisions the D1 database and R2 bucket in your account.

  • Build command: npm run build
  • Deploy command: npm run deploy
  • Keep the lockfile.

The flow must replace the placeholder D1 database ID in wrangler.jsonc; inspect that file in your clone. The deploy command applies migrations/ through the DB binding, then deploys dist/server/wrangler.json (the compiled frontend plus the Static Assets binding).

Set the account and bindings in wrangler.jsonc, create the D1 database and R2 bucket yourself, and record the real database ID. Then:

npm ci
npm run cf:types
npm run typecheck
npm test
npm run deploy:local

Configure

Add two custom domains

Add a Worker custom domain for the dashboard (for example mail.example.com) and a second one for MCP (for example mcp.mail.example.com). Reflect both in the routes array of your cloned Wrangler configuration:

"routes": [
  { "pattern": "mail.example.com", "custom_domain": true },
  { "pattern": "mcp.mail.example.com", "custom_domain": true }
]

Disable workers_dev once the custom domains work. Leave preview URLs disabled.

Protect the dashboard with Cloudflare Access

Create a self-hosted Access application for the dashboard hostname, covering all paths, and attach an allow policy for your intended users. Copy the application audience (AUD) and your Zero Trust team URL into the variables below.

Do not put interactive Access login in front of the MCP hostname. It uses its own mailbox-scoped bearer tokens and returns an authentication error without a valid one.

Set the variables

VariableValue
ACCOUNT_IDYour Cloudflare account ID
APP_ORIGINExact dashboard origin, e.g. https://mail.example.com
ACCESS_TEAM_DOMAINTeam URL, e.g. https://your-team.cloudflareaccess.com
ACCESS_AUDThe dashboard Access application's audience
MCP_HOSTMCP hostname without scheme or path, e.g. mcp.mail.example.com
EMAIL_WORKER_NAMEThe actual deployed Worker name
REDIRECT_HOSTSOptional comma-separated old dashboard hostnames (add their custom-domain routes too)

Keep variable and route changes in the Wrangler configuration; later deployments can overwrite dashboard-only settings.

Add the encryption secret

Generate the configuration-encryption key locally and store it as a Worker secret:

openssl rand -base64 32
npx wrangler secret put CONFIG_ENCRYPTION_KEY

Paste the generated value into Wrangler's prompt and never commit it. Keep it stable: replacing it without re-encrypting the provisioning token makes stored configuration unreadable. The deploy-button flow may ask for the same secret.

Build and deploy again after setting these values.

Enable Email Routing and Email Sending

  1. Enable Email Routing for the domain and complete its DNS setup deliberately. This can require changing MX records, so inspect and preserve any existing provider first.
  2. Enable Email Sending for the same domain and complete its authentication DNS verification. Receiving does not automatically imply sending.

Connect a provisioning token and create a mailbox

  1. Create a restricted Cloudflare API token for the intended account and zones with Zone Read, DNS Read, Email Routing Rules Read/Write, and Email Sending Read.
  2. In Mail HQ Settings, connect the token. It is encrypted with CONFIG_ENCRYPTION_KEY and stored in private R2.
  3. Refresh domains, then create your first mailbox.

Mail HQ checks for an existing literal routing rule before adding its own, and refuses conflicts and domains whose MX records point to an external provider.

Advanced: managed catch-all mode

The protected_addresses table can reserve existing addresses explicitly. An optional managed catch-all mode supports provisioning without a stored Cloudflare token, but it is an operator configuration: preserve existing literal rules, reserve protected addresses, and point an intentionally available catch-all at the Worker first. Do not set routing_mode='managed' just to skip routing checks. No catch-all or managed domain is enabled by the public migrations, and unknown recipients are rejected.

Verify

  • Unauthenticated dashboard requests reach Access; invalid JWTs fail at the Worker.
  • An allowed user's signed session can open the dashboard and API.
  • Requests to MCP without a token fail; a token sees exactly its mailbox and selected tools.
  • A new mailbox receives a real message and can send a reply with an attachment.
  • The received message's SPF, DKIM, and DMARC results look right. Provider acceptance is not proof of inbox placement.
  • Existing addresses, MX records, and routing rules are unchanged.
  • D1 migrations are applied and R2 has no public bucket access.

A successful build verifies packaging, not domain ownership, mail delivery, or Access policy correctness. Verify those in the target account.

Run locally

npm ci
cp .dev.vars.example .dev.vars
# Add LOCAL_DEV=true to .dev.vars for localhost-only development.
npm run db:local
npm run dev

The initial database is empty. Development bypasses Access only on localhost or 127.0.0.1 with LOCAL_DEV=true. Never set that variable in production.

npm run typecheck
npm test
npm run build