# Deploy your own

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

Canonical URL: https://kb.byaditya.com/entry/docs/mail-hq/deployment
Source: https://docs.adityabaindur.com/docs/mail-hq/deployment
Source updated: 2026-10-08T01:29:22Z
Indexed: 2026-11-05T03:58:26.143Z

---

[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/Aditya-Baindur/mail-hq)

**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

### Deploy button

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).

### Manual

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

```sh
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:

```json
"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.

**error**

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:

```sh
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

```sh
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.**

```sh
npm run typecheck
npm test
npm run build
```

