tutorials
Wire a custom domain per tenant for client inboxes
July 29, 2026

You run email for a handful of clients and each one expects mail on their own domain: inbound to
support@acme.com, replies that come fromsupport@acme.comand pass authentication. Doing this once is fiddly. Doing it for every client, without leaking one client's mail into another's inbox, is where the design decisions matter.
A custom domain per tenant means three separate jobs, and people usually only think about the first. You need inbound delivery (mail addressed to the client's domain reaches your handler), outbound authentication (mail you send from the client's domain passes SPF, DKIM, and DMARC), and tenant resolution (a given address maps to the right client's data and nobody else's). The first two are DNS. The third is application architecture, and it is the one that decides whether you have a multi-tenant product or a security incident waiting to happen.
This walks the full manual path for one client domain, calls out the per-domain records that cannot be shared, then shows where the clean isolation boundary actually sits.
How do I set up a custom domain for one client tenant?
Add the client's domain as a Cloudflare zone, point inbound MX at Cloudflare Email Routing so mail hits your Worker, publish the SPF, DKIM, and DMARC records so outbound mail authenticates, then map the address to that tenant's inbox in your app. Each of those is per-domain. Here is the whole sequence for acme.com.
Step 1: the client domain becomes a Cloudflare zone
This is the first thing that surprises people running an agency: every client domain is its own zone in Cloudflare. There is no "subaccount" abstraction that holds many domains under one routing config for free. You add acme.com to your Cloudflare account and the client repoints their nameservers (or at least delegates the records you need) to Cloudflare.
Once the zone exists, you can publish records for it. Everything below happens per zone, which is to say per client domain.
Step 2: inbound, with Cloudflare Email Routing
Mail addressed to @acme.com has to reach code you control. Cloudflare Email Routing does this by publishing its own MX records and invoking a Worker for matching mail. Enable it on the zone and it adds:
MX @ route1.mx.cloudflare.net (priority 13)
MX @ route2.mx.cloudflare.net (priority 86)
MX @ route3.mx.cloudflare.net (priority 24)
TXT @ v=spf1 include:_spf.mx.cloudflare.net ~all
The hard constraint: these must be the only MX records on acme.com. If the client still has Google Workspace or Microsoft 365 MX records present, delivery is ambiguous and your routing silently loses. Migrating a client's inbound mail means cutting their existing MX over, which is a coordination step you schedule, not a thing you sneak in. For the deep version of the inbound side, including catch-all behavior and the Worker email() handler, see the Cloudflare Email Routing catch-all walkthrough.
Point the catch-all (or explicit addresses) at your inbound Worker. Now a message to support@acme.com invokes your handler with the raw MIME, and you decide where it goes.
Step 3: outbound, and the records you cannot share
Here is the part that trips up multi-tenant setups. To send from support@acme.com and have it land in inboxes rather than spam, the message must pass SPF, DKIM, and DMARC under acme.com. Workers prohibit outbound port 25, so outbound uses the Email Service binding or REST API. External applications may instead use Cloudflare authenticated SMTP on port 465. The exact boundary is covered in sending email from a Cloudflare Worker.
Cloudflare Email Sending wants these records on the client domain:
TXT acme.com v=spf1 include:_spf.mx.cloudflare.net ~all
TXT _dmarc.acme.com v=DMARC1; p=none; rua=mailto:dmarc@acme.com
CNAME cf2024-1._domainkey.acme.com cf2024-1._domainkey.mx.cloudflare.net
MX cf-bounce.acme.com route1.mx.cloudflare.net (priority 14)
TXT cf-bounce.acme.com v=spf1 include:_spf.mx.cloudflare.net -all
The DKIM record is the one that cannot be shared across tenants, and it is worth understanding why. DKIM signs each message with a private key, and the receiver fetches the matching public key from <selector>._domainkey.<From-domain>. The signature is bound to the From domain. So acme.com and globex.com each need their own DKIM record under their own domain. There is no shared key that signs for everyone; a receiver checking mail from support@acme.com looks only at cf2024-1._domainkey.acme.com, full stop. Confirm the exact selector Cloudflare returns for the domain at onboarding, because the selector name can change.
DMARC starts at p=none so you observe before you enforce. Once you have a week of clean aggregate reports showing SPF and DKIM aligned, tighten the client's policy to quarantine then reject. If reading those rua reports is new to you, see the email deliverability guide for how the auth records fit together end to end.
Step 4: map the address to the tenant
DNS gets mail to and from the domain. Your application still has to know that support@acme.com belongs to Acme's inbox and not Globex's. The minimum is a table that maps an address to a tenant and inbox:
CREATE TABLE inbox_addresses (
address TEXT PRIMARY KEY, -- "support@acme.com"
domain TEXT NOT NULL, -- "acme.com"
inbox_id TEXT NOT NULL, -- which logical inbox
tenant_id TEXT NOT NULL, -- which client
is_primary INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_inbox_addresses_inbox ON inbox_addresses(inbox_id);
Inbound, you look up message.to in this table to find the inbox. Outbound, you do the reverse: a reply's From should be the address the original message was delivered to, not a hardcoded default, so a thread that arrived at support@acme.de replies from support@acme.de and not support@acme.com. Get this wrong and a client's reply goes out under another domain, fails alignment, and lands in spam, or worse, exposes which domains you host.
This is also the layer where multi-tenancy lives or dies. Every query that reads or writes mail has to be scoped by tenant_id. A single missing WHERE tenant_id = ? is the difference between a tidy multi-tenant inbox and one client reading another's support queue.
Can one deploy serve many client domains?
Technically yes, with an important asterisk. A single deploy can route several domains into inboxes, and several addresses across domains can even share one logical inbox: support@acme.com and support@acme.de can be the same shared thread list. That shared-inbox model is genuinely useful when one organization owns multiple domains.
But sharing a deploy means sharing a trust boundary. All those domains live in the same database, behind the same admin login, on the same Worker, billed together. For domains owned by the same client that is fine and often what you want. For different clients it is not the boundary you want, because:
- An admin or agent on the deploy can, by design, reach the data the deploy holds. Cross-tenant isolation inside one database depends entirely on you never missing a
tenant_idfilter. - One client's send reputation problems, abuse complaints, or DMARC failures sit on infrastructure shared with everyone else on that deploy.
- Billing, backups, and "give me an export and delete my data" all operate at the deploy level, not the per-client level.
So the honest line is: multiple domains per deploy is real and works for one client's family of domains. Multiple clients per deploy is a sharp tool, and the safer default for an agency is one deploy per client.
What is the clean isolation boundary for an agency?
One brand-scoped deploy per client, with many client deploys sharing a single Cloudflare account. This gives each client their own database, their own R2 bucket, their own admin login, and their own send reputation, while you still manage everything from one Cloudflare login and one bill.
The naming is what makes this work without collisions. Each deploy is tagged with an org slug (defaulting to the domain slug, overridable with --org), and every resource is named tinbox-<org>-<service>:
client "acme":
tinbox-acme (Worker) tinbox-acme-db (D1)
tinbox-acme-mail (R2)
client "globex":
tinbox-globex (Worker) tinbox-globex-db (D1)
tinbox-globex-mail (R2)
Two clients on the same Cloudflare account never collide because their workers, databases, and buckets carry the brand. There is no shared database to leak across, so the tenant_id discipline from the manual path becomes a non-issue between clients: the isolation is at the infrastructure level, not a column you have to remember to filter on. Within a single client's deploy you can still attach that client's multiple domains and share inboxes across them. How many brand-scoped deploys live and stay manageable on one Cloudflare account is the subject of running multi-tenant email on one Cloudflare account.
The cost of this model is that adding a client is a deploy, not a row in a table, and a client's domains still each need their own zone and DNS records. There is no console where you click "add tenant" and a fully isolated stack appears with shared control-plane SSO across all of them. A unified control plane across deploys is a design we have sketched, not something shipped today. What exists and works is: per-client deploys, brand-named, many to a Cloudflare account, each with the inbound and outbound DNS this article walks through.
Or skip the per-domain glue
Everything above is the real procedure, and it is worth knowing because it is what is actually happening underneath. But running it by hand for every client, the zone, the inbound MX, the per-domain DKIM CNAME, the bounce subdomain, the address-to-tenant mapping, the reply-from logic, is a lot of careful, repeatable work where a single wrong record sends a client's mail to spam.
The Tinbox email stack does this provisioning per client with two commands: npx tinbox init scaffolds a project branded to the domain (deriving an org slug that scopes every resource), and tinbox setup provisions it on the client's (or your) own Cloudflare account — creating the D1 database, R2 bucket, KV namespace, and queues, then deploying the Worker. After deploy you enable Email Routing and publish the sending-domain records tinbox doctor prints.
npx tinbox init acme.com # scaffold + brand to acme.com (org: acme)
cd acme
tinbox setup --deploy # provision D1/R2/KV/queues + deploy the Worker
✔ Provisioned D1 database (tinbox-acme-db)
✔ Provisioned R2 bucket (tinbox-acme-mail)
✔ Deployed Worker tinbox-acme
Next: enable Email Routing on acme.com and add a catch-all -> tinbox-acme.
Sending-domain records to publish:
TXT acme.com -> v=spf1 include:_spf.mx.cloudflare.net ~all
TXT _dmarc.acme.com -> v=DMARC1; p=none; rua=mailto:dmarc@acme.com
CNAME cf2024-1._domainkey.acme.com -> cf2024-1._domainkey.mx.cloudflare.net
MX cf-bounce.acme.com -> route1.mx.cloudflare.net (priority 14)
Run init again with a different domain for the next client (override the slug with --org if the derived one collides) and you get a second, fully separate stack on the same account. The address-to-tenant mapping, the reply-from-delivered-address logic, and the per-domain DKIM signing are handled inside the deploy rather than being yours to maintain. The whole flow, end to end, is in the npx tinbox init walkthrough; for the agency angle — branding the inbox and sender per client — see white-labeling the inbox and sender for each client.
Where to go from here
- white-label email for clients: theming and branding each client deploy, with the same one-deploy-per-client model.
- Cloudflare Email Routing catch-all: the inbound half in depth, including the Worker
email()handler. - sending email from a Cloudflare Worker: why the outbound side is HTTPS, not SMTP, and how DKIM signing fits.
- email deliverability guide: making sure every client domain's SPF, DKIM, and DMARC actually pass.
- the DNS records every sending domain needs: the per-domain record set you publish for each client zone.
Deploy this yourself: npx tinbox init acme.com, then add the next client with a fresh init (override the resource slug with --org if needed). Full per-tenant configuration options are in the docs.
Frequently asked questions
How do I set up a custom sending and receiving domain for each client tenant?
For each client domain you add inbound MX records (Cloudflare Email Routing publishes route1/route2/route3.mx.cloudflare.net) so mail lands on a Worker, and outbound auth records (an SPF TXT, a DKIM CNAME selector, and a DMARC TXT) so mail you send passes authentication. Then your application maps the incoming address to that tenant's inbox. Because each domain is a separate Cloudflare zone, the records must be added to each client's DNS individually.
Can one Cloudflare email deploy serve multiple client domains?
A single deploy can route several domains and addresses into shared inboxes (support@acme.com and support@acme.de can be the same logical inbox). But every domain is still its own Cloudflare zone needing its own MX, DKIM, and DMARC records, and a single deploy is a single trust and billing boundary. For real per-client isolation, where one client's admin can never see another's mail, run a separate brand-scoped deploy per client in the same Cloudflare account.
Why does each client domain need its own DKIM record?
DKIM proves the sending domain authorized the message by signing it with a private key whose public key lives in that domain's DNS. The signature is bound to the From domain, so client-a.com and client-b.com each need their own DKIM record (a CNAME to a Cloudflare-issued selector). You cannot share one DKIM key across client domains because receivers check the selector under the exact From domain.
Can a Cloudflare Worker receive and send mail for a custom domain?
It can receive through Cloudflare Email Routing, which invokes the Worker's email() handler for a domain whose MX points at Cloudflare. For outbound, port 25 direct-to-MX is blocked; use Cloudflare Email Service through its Worker binding or HTTPS API, or an allowed third-party submission service through outbound TCP.
How many client deploys can live in one Cloudflare account?
Many. Tinbox names every resource tinbox-<org>-<service>, so two clients on the same account get distinct workers, D1 databases, and R2 buckets (tinbox-acme, tinbox-acme-db, and so on). The account-level limit you hit first is usually Workers and zone counts on your plan, not anything in the email stack itself.


