Skip to content

guides

Multi-tenant email on Cloudflare: one account, every tenant

May 15, 2026

#multi-tenant#cloudflare#agency#brand-scoped#deploy
Multi-tenant email on Cloudflare: one account, every tenant
Visual: Paolo Muted on okslop

"Multi-tenant" usually means one app quietly juggling many customers in a shared database. In Tinbox it means the opposite: many fully isolated deploys living in one Cloudflare account. Getting that mental model right up front saves you from designing around a system that does not work the way you assume.

If you run email for more than one brand (an agency with clients, a holding company with several products, a builder with side projects on different domains), the first question is how the tenancy actually works. The honest answer for Tinbox is unusual enough that it is worth being precise about, because the wrong assumption leads to the wrong architecture. This guide is the technical mechanics: how brand-scoped naming works, how many tenants fit in one Cloudflare account, where the isolation boundaries are, and what is genuinely shipped versus what is still on the drawing board. The business and margin side (pricing clients, packaging the service) lives in the agency playbook on top of this model; this article is the plumbing.

What does "multi-tenant" mean in Tinbox?

In Tinbox, multi-tenant means many brand-scoped deploys in one Cloudflare account, each fully isolated, not one shared application serving all tenants from a common database. This is the most important sentence in the article, because the word "multi-tenant" carries a default assumption that is wrong here.

The industry-default reading of multi-tenant is row-level multi-tenancy: one running application, one database, every table carrying a tenant_id column, every query carrying a WHERE tenant_id = ?. It is cheap to operate and one forgotten WHERE clause away from a breach.

Tinbox does the other thing. Each tenant is a separate deploy:

Cloudflare account "agency"
├── tinbox-acme-*       (tenant: acme.com)
│     ├── Worker:     tinbox-acme       (one bundled worker: fetch/email/queue/scheduled)
│     ├── D1:         tinbox-acme-db
│     ├── R2:         tinbox-acme-mail
│     └── Queues:     tinbox-acme-newsletter, tinbox-acme-projection, ...
├── tinbox-globex-*     (tenant: globex.io)
│     ├── Worker:     tinbox-globex     (one bundled worker)
│     ├── D1:         tinbox-globex-db
│     ├── R2:         tinbox-globex-mail
│     └── Queues:     tinbox-globex-newsletter, tinbox-globex-projection, ...
└── tinbox-initech-*    (tenant: initech.com)
      └── ... its own Worker(s) + D1 + R2 + Queues

There is no shared tenants table. There is no shared D1 database. tinbox-acme-db and tinbox-globex-db are two different SQLite databases that never see each other. The thing they have in common is the Cloudflare account they are billed and quota'd against. That is the whole model: one account, N independent stacks.

The payoff is that "did I leak tenant A's mail to tenant B" is not a question you can answer wrong with a missing WHERE clause, because there is no shared query path to forget. The cost is that you operate N deploys instead of one. Both halves are real, and the rest of this guide is mostly about living with the second half well.

How does the tinbox-<org>-<service> naming scheme work?

Every resource a tenant owns is named tinbox-<org>-<service>, where org is a per-tenant slug and service identifies the component. The tinbox prefix is fixed; the org slug is what makes many tenants coexist in one account without name collisions.

Cloudflare resource names (Worker names, D1 database names, R2 bucket names) are unique within an account. If two tenants both tried to create a Worker called tinbox-api-worker, the second deploy would either fail or overwrite the first. Earlier Tinbox builds had exactly this bug: worker names were hardcoded literals with no tenant slug, so a second tenant in the same account collided with the first. The fix was to thread an org slug through every generated name.

The org slug comes from one of three places, in precedence order:

  1. The explicit --org flag you pass to init or the deploy script.
  2. The slug already persisted in the tenant's infra/tenant.json (so re-running a deploy keeps stable names).
  3. The domain slug, as a fallback, when you pass neither.

So acme.com with no --org derives the org slug acme, and you get tinbox-acme, tinbox-acme-db, and so on. The default-to-domain-slug behavior is deliberate: a single-tenant deploy that never touches --org still gets stable, predictable names. In scripts, though, pass --org explicitly so the slug is a deliberate choice that matches your naming convention rather than whatever the domain happens to derive to.

The practical rule: pick a short, stable org slug per tenant and never change it. Renaming it renames every resource, which from Cloudflare's perspective means creating new empty databases and buckets next to the old ones, not migrating data.

Where are the isolation boundaries?

Data, domains, and sending reputation are isolated per deploy; the Cloudflare account (billing and account-wide limits) is shared. Knowing exactly which line each thing falls on is what lets you reason about blast radius.

ConcernIsolated per tenant?Notes
Email data (messages, threads)YesSeparate D1 database per deploy. No shared query path.
Attachments / blobsYesSeparate R2 bucket per deploy.
Send pipelineYesSeparate Queues and send Worker per deploy.
Sending domain + reputationYesEach tenant sends from its own domain with its own SPF, DKIM, DMARC.
A bad migration / bad deployYesHits one tenant; the others keep running.
Cloudflare billingNoOne invoice for the account; usage aggregates across tenants.
Account-wide resource limitsNoWorkers count, D1 count, and Queues count are account quotas.
A Cloudflare account-level incidentNoAn account suspension or platform outage affects every deploy in it.

The reputation isolation is worth dwelling on because it is the one that quietly bites shared-pool SaaS users. When tenant A sends to a stale list and racks up spam complaints, that reputation damage is scoped to tenant A's domain. Tenant B, on a different domain with its own DKIM key, is unaffected. On a shared sending pool you do not get that, and one noisy neighbor drags everyone's deliverability down. Each tenant in Tinbox gets the per-deploy sender bound to its own domain, so the reputation surface is genuinely per-tenant.

The boundary you do share is the account. If you pack a hundred tenants into one Cloudflare account and that account hits a limit or gets flagged, all hundred feel it at once. For most agencies one account per a manageable fleet is fine; for very large fleets, sharding tenants across multiple accounts is the escape hatch, and the per-deploy model makes that move clean because nothing is entangled across deploys.

How do I provision a new tenant?

You provision a tenant with one command: npx tinbox init <domain> --org <org>. It scaffolds and deploys a complete, org-scoped stack into the current Cloudflare account.

npx tinbox init tenantb.com --org tenantb

The init flow reports the org-scoped resource names it is creating, so you can confirm nothing collides with an existing tenant:

tinbox init  tenantb.com  (org: tenantb)

Resolved org: tenantb
Planned Cloudflare resources in this account:
  Worker
    tinbox-tenantb
  D1
    tinbox-tenantb-db
  R2
    tinbox-tenantb-mail
  Queues
    tinbox-tenantb-newsletter
    tinbox-tenantb-newsletter-dlq
    tinbox-tenantb-projection

Sending domain: tenantb.com   (send host: send.tenantb.com)
DNS to publish: SPF, DKIM, DMARC for tenantb.com

Deploy now? [y/N]

Every line is prefixed tinbox-tenantb-, which is exactly why it can sit next to tinbox-acme-* and tinbox-globex-* in the same account without overwriting anything. The DNS guidance is per-domain because each tenant authenticates as its own domain; you publish that tenant's SPF, DKIM, and DMARC, and you do it again for the next tenant on the next domain.

After deploy, you can confirm a tenant is live and inspect its resolved names without guessing, by running the doctor against that tenant's directory:

npx tinbox doctor --org tenantb
tinbox doctor  (org: tenantb)

  Worker tinbox-tenantb               reachable
  D1     tinbox-tenantb-db             3 migrations applied
  R2     tinbox-tenantb-mail           bound
  Queue  tinbox-tenantb-newsletter    consumer attached
  DNS    tenantb.com                   SPF ok, DKIM ok, DMARC ok

OK  tenant tenantb is healthy

That this runs independently per tenant is the point: each tenant is a self-contained stack you can health-check on its own. The full single-tenant init walkthrough is the same flow without the multi-tenant framing; the cost side, per tenant, is in the cost-per-deploy breakdown, and what one deploy actually contains is the hub if you want the component-by-component tour.

How do I operate N tenants in one account?

You operate many tenants by scripting the per-tenant deploy across a tenant list and watching account-level resource counts. There is no shared control plane today, so "operating the fleet" means looping the per-deploy operation, honestly.

A real provisioning script is just a loop. The honesty here is that it is N deploys, and the script makes that explicit rather than hiding it:

#!/usr/bin/env bash
set -euo pipefail

# Each line: <domain> <org>
tenants=(
  "acme.com acme"
  "globex.io globex"
  "initech.com initech"
)

for entry in "${tenants[@]}"; do
  read -r domain org <<< "$entry"
  echo "==> provisioning $domain (org: $org)"
  npx tinbox init "$domain" --org "$org" --yes
done

Upgrades work the same way: there is no automatic cross-deploy upgrade, so rolling out a new Tinbox version means re-running the upgrade per deploy. Script it the same way you scripted provisioning, and roll it out in waves rather than all at once so a bad release hits one tenant, not all of them.

Before you pack a lot of tenants in, do the arithmetic against Cloudflare account limits. Each tenant costs roughly:

  • 1 Worker (one bundled worker per tenant: fetch/email/queue/scheduled)
  • 1 D1 database
  • 1 R2 bucket
  • 3 Queues (newsletter + dead-letter + projection)

So 30 tenants is roughly 120 Workers, 30 D1 databases, and 60 Queues in the account. Workers count is usually generous; D1 database count and Queues count are the limits that bite first, so check those against your plan before committing to a large fleet in a single account. When you approach a limit, shard the next batch of tenants into a second Cloudflare account; because deploys are independent, nothing has to be untangled.

What is designed but not built yet?

Two things people expect from "multi-tenant" are designed but not shipped as mature features: multi-domain-per-instance and a centralized control-plane with SSO. Plan around the per-deploy model that exists today, not the one on the roadmap.

This is the part of the article most likely to be misread, so it gets stated plainly:

  • Multi-domain-per-instance is not a mature, supported feature. The reliable model is one deploy per tenant domain. There is design work and early progress toward letting a single deploy own several domains as a shared-inbox-style container, but you should not architect a client engagement on the assumption that one deploy will cleanly serve three unrelated brand domains today. If you need three domains, run three deploys. That is the supported path, and it is genuinely fine.
  • A centralized control plane is a design, not a product. There is no single management console that lists every tenant in your account, pushes upgrades fleet-wide, or gives you one login across all deploys. Centralized Tinbox auth as an OIDC identity provider (SSO across tenants) is a design on paper. Today, "managing the fleet" is the scripting in the previous section: loops over independent deploys.

The reframe that keeps you honest and still productive: separate deploys are the unit of tenancy today. Everything you might want from a shared control plane (provisioning, health checks, upgrades) you can get by scripting the per-deploy commands. It is more deploys to run, and in exchange you get isolation you did not have to engineer.

What are the common failure modes?

The three failures you will actually hit are org-slug collisions, account resource limits, and forgetting --org. All three are predictable and easy to avoid once named.

Org-slug collision. Two tenants given the same org slug produce the same resource names, and the second deploy clobbers the first. The init flow tries to surface this, but if you force past it the result is data loss:

$ npx tinbox init clientb.com --org acme --yes

Resolved org: acme
WARNING: resources named tinbox-acme-* already exist in this account
         (owned by an earlier deploy for acme.com).
  D1 tinbox-acme-db        ALREADY EXISTS
  R2 tinbox-acme-mail      ALREADY EXISTS

Continuing will point clientb.com at acme.com's existing database.
Aborting. Re-run with a distinct --org.

The fix is a distinct org slug per tenant:

npx tinbox init clientb.com --org clientb

Now the names are tinbox-clientb-*, they do not overlap tinbox-acme-*, and the two tenants coexist. The rule that prevents this entirely: one org slug per tenant, chosen once, written down.

Hitting an account resource limit. A deploy fails partway because the account is out of D1 databases or Queues. The failure is loud (Cloudflare returns a quota error), and the fix is either to raise the limit on your plan or to provision the next batch of tenants in a second account. Do the per-tenant-footprint arithmetic before you start a large fleet so you find this on a spreadsheet, not at deploy 31.

Forgetting --org. Omitting --org is not an error; it silently defaults the org slug to the one derived from the domain. That is usually fine, but the derived slug may not match the convention your scripts and other tenants follow, so you end up with one tenant named off-pattern from the rest. There is no data harm, but it is annoying, so decide your convention (always pass --org, or always rely on the default) and apply it uniformly. In scripts, pass --org explicitly so the slug is deliberate.

When would you actually want this?

You want multi-tenant-as-many-deploys when you run email for several distinct brands or domains and you care about isolating their data and sending reputation. The model fits agencies, multi-product companies, and builders with several domains.

  • Agencies running email for a roster of clients, where one client's spam complaint must not touch another client's deliverability, and where "we host this on our own infrastructure" is part of the pitch. The economics of that are in the agency margin playbook.
  • Multi-product companies where acme.com, acme-labs.com, and acme-status.com each want their own sender and inbox, isolated, but billed to one engineering account.
  • Builders with side projects on different domains who want one Cloudflare account, one mental model, and clean separation between projects.

In every case the shape is the same: one Cloudflare account, several tinbox-<org>-* deploys, each isolated by construction. You trade operating more deploys for never having to engineer (or trust) row-level tenant isolation, and for sending reputations that cannot bleed across tenants. If you want to wire your own logic into each tenant's Worker, the tinbox/cf library is the per-deploy extension point. For the operational shortcuts that keep a growing fleet sane, these multi-tenant email tips cover naming, quota arithmetic, and rollout discipline in a quick checklist.

Deploy this yourself: npx tinbox init <domain> --org <org>. Run it once per tenant, all on your own Cloudflare account. The init and doctor flows in the docs walk the per-tenant provisioning and health checks end to end, and the naming scheme means tenant number 30 sits next to tenant number 1 without a collision.

Frequently asked questions

Is multi-tenant Tinbox one app serving all tenants, or one deploy per tenant?

One deploy per tenant. Tinbox does not use a shared multi-tenant database with a tenant_id column. Each tenant is a separate, brand-scoped deploy (its own Worker, D1 database, R2 bucket, and Queues), and many of those deploys live in a single Cloudflare account. This is the shipped model, and it is what 'multi-tenant' means here: multi-deploy, not row-level multi-tenancy.

Are tenants isolated from each other in a multi-tenant Cloudflare email setup?

Yes, at the deploy level. Because each tenant gets its own D1 database, its own R2 bucket, and its own domain, there is no shared query path that could leak one tenant's mail into another's, and one tenant's spam complaints do not affect another tenant's sending reputation. The boundary they do share is the Cloudflare account: billing and account-wide resource limits are common. So data and reputation are isolated; the account quota is not.

How many tenants can I run in one Cloudflare account?

It is bounded by Cloudflare account-level limits, not by Tinbox. Each tenant consumes roughly four Workers, one D1 database, one R2 bucket, and a set of Queues, so you divide your account's Workers, D1, and Queues limits by the per-tenant footprint. On paid plans that is comfortably dozens of tenants per account; very large fleets eventually want multiple accounts. Watch the D1 database count and Queues count first, since those are usually the tightest per-tenant resources.

What does the --org flag do in npx tinbox init?

The --org flag sets the org slug that scopes every resource name for a tenant, producing names like tinbox-acme and tinbox-acme-db. It defaults to the domain slug, so acme.com becomes org acme if you omit it. The org slug is why two tenants in the same Cloudflare account do not collide on resource names. If two tenants accidentally share an org slug, their resource names overlap and the second deploy clobbers the first, so distinct slugs matter. (--brand is a deprecated alias for --org.)

Can one Tinbox deploy serve multiple domains?

Treat one deploy as one brand and one primary domain for planning purposes. Multi-domain-per-instance is designed and partially in progress, but it is not a mature, shipped feature you should build a business on today. The reliable, supported model is one deploy per tenant domain. If a client has three brands on three domains, that is three deploys in your account, each fully isolated.