Documentation
Deploy your own
Run Mail HQ in your own Cloudflare account, on your own domains, behind Cloudflare Access.
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
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:localConfigure
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
| Variable | Value |
|---|---|
ACCOUNT_ID | Your Cloudflare account ID |
APP_ORIGIN | Exact dashboard origin, e.g. https://mail.example.com |
ACCESS_TEAM_DOMAIN | Team URL, e.g. https://your-team.cloudflareaccess.com |
ACCESS_AUD | The dashboard Access application's audience |
MCP_HOST | MCP hostname without scheme or path, e.g. mcp.mail.example.com |
EMAIL_WORKER_NAME | The actual deployed Worker name |
REDIRECT_HOSTS | Optional 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_KEYPaste 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
- 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.
- 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
- 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.
- In Mail HQ Settings, connect the token. It is encrypted with
CONFIG_ENCRYPTION_KEYand stored in private R2. - 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 devThe 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