Skip to content

Deploy MailySend to your own Cloudflare account

One click, about a minute, and the resources it creates in your account. What the deploy button actually provisions, and what it deliberately leaves for you.

DeployBeginner9 min readUpdated

01What the deploy actually provisions

The deploy creates eight kinds of resource. All of them are in your account, billed to you, visible in your dashboard, and deletable by you.

TL;DR

There is no MailySend-operated server anywhere in the path — which is the argument for this product, and also why nobody here can recover your data for you if you delete it.

ProductWhat it holdsNotes
WorkersThe application itselfOne script, serving the API, the dashboard and this site. It also holds every queue consumer, the email() handler and three cron triggers.
QueuesSends, events and webhooksTwelve, counting the dead-letter queue — listed below.
Durable ObjectsCoordinationTen classes, all SQLite-backed — broadcast ranges and counters, sending domains, mailboxes, segments, schedule shards, webhook endpoints and automation runs.
D1The databaseContacts, messages, domains, keys. SQLite, in your account.
KVHot lookupsTwo namespaces. CACHE holds link destinations and cached configuration; SUPPRESSIONS is separate on purpose, because it is permanent and must never share an eviction story with a 300-second key cache.
R2Attachments and inboundRaw messages, attachments and the long-term NDJSON archive.
WorkflowsAutomationsLong-running, durable multi-step programs. The driver is chosen from the runtime’s own capability rather than from configuration, so it is Workflows here and the scheduler on Node.
Analytics EngineChart queriesLeft out of the deploy unless you ask for it. The datasets need no provisioning; they are created on first write.
QueueProducer bindingBatch / timeoutRetries
ms-sendSEND_QUEUE10 / 5s5
ms-send-bulkSEND_BULK_QUEUE25 / 10s5
ms-events-cfconsumer only100 / 5s3
ms-events-normEVENTS_QUEUE100 / 5s3
ms-webhooksWEBHOOKS_QUEUE10 / 2s6
ms-broadcast-pagesBROADCAST_QUEUE5 / 2s5
ms-inboundINBOUND_QUEUE5 / 5s3
ms-segmentsSEGMENTS_QUEUE20 / 10s4
ms-automation-triggersAUTOMATION_QUEUE50 / 5s4
ms-dmarcDMARC_QUEUE1 / 30s3
ms-exportEXPORT_QUEUE1 / 30s3
ms-dlqdead letter for all elevenno consumer

ms-events-cf has no producer here because Cloudflare itself publishes to it. Every consumer dead-letters to ms-dlq, which has no consumer of its own.

Who actually creates them

Not the button, mostly. The Deploy to Cloudflare flow auto-creates the bindings it knows how to auto-create — KV, D1, R2 — and does not create queues at all. So build:cf ends by running scripts/ensure-resources.mjs, which creates the twelve queues, the R2 bucket, the D1 database and the two KV namespaces, then writes the generated D1 and KV ids into the config wrangler deploys.

It matches by name and creates only what is missing, so every build after the first is a no-op. That property is load-bearing rather than tidy: re-creating SUPPRESSIONS instead of reusing it would silently empty the one list that must never be lost. It also refuses to run outside CI — only inside Workers Builds (WORKERS_CI=1) or under MS_ENSURE_RESOURCES=1 — so building locally never creates resources in your account as a side effect of a build.

There is no id in the committed config. apps/app/wrangler.jsonc carries no database_id and no KV id, because a generated id cannot be committed and a literal placeholder is worse than omitting one — it passes JSON validation and then fails the deploy with KV namespace 'PLACEHOLDER' is not valid. Omitted, the resource gets created and its real id written in. If the build token cannot create something, the script leaves the id out rather than failing the build, which is exactly the shape wrangler’s own provisioning expects, so the deploy gets a second attempt at it.

ANALYTICS ENGINE IS OPT-IN
The dataset bindings are dropped from the generated config unless you build with MS_ANALYTICS_ENGINE=1, because an account that has not enabled Analytics Engine fails the entire deploy with You need to enable Analytics Engine … [code: 10089] after the upload. The binding is not load-bearing: Analytics Engine is the hot query layer for charts — sampled under load, three months of retention — while every count of record comes from D1, and the events consumer already treats the binding as optional.

At rest — nothing sending — this costs the Workers Paid floor and very little else. The cost guide prices it properly, including the point below which a vendor’s free tier is genuinely the cheaper answer.

02Run the deploy

Two paths to the same place.

TL;DR

The button is faster; the commands let you watch each resource appear, which is worth doing once if you are going to operate this.

Deploy to Cloudflare button
  • Forks the repository into your GitHub account and connects it to Workers Builds
  • Nothing to fill in first — every variable has a default or is generated on first boot
  • Uses Cloudflare’s own build token, so no CLOUDFLARE_API_TOKEN of your own
Wrangler, by hand
  • Needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in your shell
  • The token needs Queues:Edit, plus Workers Scripts:Edit, D1:Edit, Workers KV:Edit and Workers R2:Edit
  • Every call is idempotent, so re-running on a provisioned account prints “exists” rather than an error
1 · Deploy

The one-click deploy builds and deploys, which is where the tables above actually get provisioned. .env.example carries no keys at all on purpose: Cloudflare builds the deploy form from that file, shows key names without their comments, stores every answer as a secret and does not prefill from the file — so a key with a perfectly good default would render as a blank, masked, mandatory-looking password box.

Deploy to Cloudflare
2 · Or do it yourself
$ git clone https://github.com/GagnDeep/mailysend
$ pnpm install
$ export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=...
$ npx mailysend provision
$ pnpm deploy:cf

provision is the interesting one: it creates the queues and the bucket and checks that the token can read the Analytics Engine datasets.

3 · Check it answered
GET /v1/health
$ curl https://your-worker.workers.dev/v1/health
{ "status": "operational",
"mode": "single",
"version": "…",
"database": "ok",
"time": "2026-09-11T09:14:02.118Z" }

Deliberately unauthenticated, because a load balancer cannot hold a key. It runs a SELECT 1 against D1 and answers 200 when that works; otherwise the status is degraded, the database field carries the driver’s own error text, and the response code is 503 so that a health check fails rather than reads a body nobody looks at.

The commands Workers Builds guesses

Set thisTo thisBecause
Build commandpnpm run build:cfbuild:node emits a socket server, not a Worker.
Deploy commandnpx wrangler deploy -c apps/app/.output-cf/server/wrangler.jsonThat is the config Vite generates next to the bundle. apps/app/wrangler.jsonc is the input to that generation, and deploying it directly points main at TypeScript source.

Workers → your Worker → Settings → Builds. They work without being set; this is what they are.

The other guess fails just as obliquely. Cloudflare then proposes a bare npx wrangler deploy from the repository root, where wrangler cannot tell which workspace package is the Worker and stops before doing anything. So build:cf ends by writing a root wrangler.json — a copy of the generated config with its path fields rewritten, regenerated on every build and gitignored, so bindings still have one source of truth.

03What happens on first boot

The first request to a fresh instance does four things, and one of them you have to be watching for.

On first requestWhat happensRecoverable later?
MigrationsRuns every migration the instance does not already have.Idempotent
Workspace + API keyCreates the workspace and prints one bootstrap API key.Printed once — only the SHA-256 is stored
Claim codeMints and prints one claim code, the lock on /setup.Printed once — only the SHA-256 is stored
The two undefaultable valuesResolves the signing secret and the public URL, then persists them.Yes — both are learned or generated, not configured

The public URL is learned, not configured. Nothing pins it in wrangler.jsonc on purpose: a placeholder hostname there would mint every tracking pixel, unsubscribe link and canonical tag against a domain nobody owns. Instead the first non-local request’s origin is stored and reused, so a deployment first reached on workers.dev and later on a custom domain adopts the custom domain, and a localhost origin is never stored — which is what stops a development request poisoning a real deployment.

The signing secret is generated and kept. If MS_SECRET is unset, a 32-byte value is generated on first boot and written to the settings table under a conditional insert, so two isolates racing on a cold deployment adopt one value rather than overwriting each other’s. Setting it explicitly is still the better posture — it keeps the signing key out of the database and lets you rotate it — but it is not something to solve before you can deploy.

What every pre-auth screen reads. GET /v1/instance is unauthenticated and reports whether the instance is claimed, whether a claim code is required, whether the claim is reserved to an address, which sign-in doors are actually open, and whether any domain is verified. It carries no secrets and no per-address facts, which is the point: a door is only drawn on the page when it is open, and a button that answers 501 is worse than no button.

THE CLAIM WINDOW IS OPEN
Until someone claims it, a freshly deployed instance will accept a claim from whoever reaches /setup first — subject to the claim code, which is the lock that makes a public URL safe before you get to it. If you would rather narrow it to one address instead, set MS_OWNER_EMAIL with wrangler secret put (it is deliberately not on the deploy form). When it is set, the code is not asked for — one lock rather than two — and the next guide covers the whole flow.

04What the deploy deliberately does not do

Four things the deploy deliberately leaves to you.

TL;DR

Each one is left undone because automating it would make the system less trustworthy rather than more convenient.

01
Your DNS is not touched

Publishing SPF, DKIM and DMARC records changes how the entire internet treats mail from your domain. A deploy button that silently rewrote your zone would be doing something you cannot easily inspect or undo, at exactly the moment you have the least context. The DNS guide generates your records and explains each one.

02
No domain is verified

Verification is a claim that you control a domain, and pre-verifying one would make the claim meaningless. It has a visible consequence you should expect rather than debug: until a domain is verified there is nothing to send an email with, so POST /v1/auth/otp answers 202 {"status":"unavailable"} and the sign-in page does not offer emailed codes at all.

03
No transport is configured

Which service carries your mail has real consequences for cost, attachment size and what event data you get back — Cloudflare caps a message at 5 MiB where SES allows 40 MB, to name the one that surprises people. Defaulting it would be picking for you and hoping you never looked. See choosing a transport.

04
Receiving is not wired up

Sending and receiving are independent setups on the same domain, and receiving is configured in two places: Email Routing in the Cloudflare dashboard needs a catch-all rule whose action is Send to a Worker pointed at this script, and the domain’s Receiving tab in MailySend needs a mailbox.

05If the deploy fails

Five failures account for nearly every deploy that does not come up, cheapest to check first.

TL;DR

The last two are the ones whose error messages point somewhere other than the cause.

SymptomCauseFix
CPU limits are not supported for the Free plan [code: 100328]A limits.cpu_ms in the Worker config. The CPU limit is a fixed 10 ms on the Free plan and cannot be set at all, so the API rejects the upload — after every resource has already been created, which is what makes it read like a provisioning failure.Remove the limits block. MailySend ships without one, because 30,000 ms was already the Workers Paid default and setting it explicitly bought nothing. If you have raised it on purpose, you have made your deploy paid-only.
A binding error during provisioning, not a billing messageA resource the account cannot create. Note that this is not usually the plan: every binding here works on Workers Free — the ten Durable Objects are all declared as new_sqlite_classes, and Queues and Workflows both have free tiers. Analytics Engine is the one that needs enabling, and it is already left out unless you ask for it.Read which binding the error names rather than assuming billing. You still want Workers Paid to actually run this — 10 ms of CPU is not much of a budget, and Cloudflare Email Service is Workers Paid only, so the default transport cannot send without it — but the plan is not what makes the deploy succeed, and treating it as the answer hides the binding that really failed.
Every queue collides at onceA second MailySend deployment in the same account. A queue has exactly one consumer and queue names are account-global.Deploy the second one to a different Cloudflare account, or remove the first Worker’s consumers.
A failed deploy naming a queue or namespace, after a clean buildThe build token cannot create resources. ensure-resources.mjs does not fail the build when a creation is refused; it leaves the id out and lets wrangler try.Check the token carries Queues:Edit.
You need to enable Analytics Engine … [code: 10089]The bindings were included — MS_ANALYTICS_ENGINE=1 — on an account where the feature has not been enabled.Enable it on the account, or build without that variable and let D1 answer the charts.
The deploy succeeds and the first send failsEmail Sending is not enabled on the account.Cloudflare Email Service is in beta, Workers Paid only, with a daily quota that ramps with reputation and is not published — MailySend learns that ceiling rather than assuming one.

When the deploy fails on queue consumers

✘ [ERROR] Trigger configuration for "…" was only partially updated:
    Queue consumers:
      - A request to the Cloudflare API (/accounts/…/queues) failed.
        - An unknown error has occurred [code: 10013]

The script uploaded; only the trigger update failed, and the build is reported as failed regardless. The message names neither the queue nor the reason — but the list of consumers wrangler prints immediately above it does: whichever declared queue is missing from that list is the one that failed. The two causes are rows two and three of the table above, a queue that was never created and a queue another Worker already consumes.

If none of that is itWhat it tells youCost
GET /v1/healthWhether the database resolved.One request, nothing to set up
GET /v1/instanceWhat the deployment believes about itself — claimed or not, which doors are open, whether any domain is verified.One request, nothing to set up
wrangler tailWhat the Worker did instead.Needs you to reproduce the failure while watching

In that order, and for that reason.

What just happened

You have a Worker running in your own Cloudflare account, with its own database, queues and storage, and nothing shared with anyone else. It is not yet claimed and it cannot yet send, because both of those need a decision from you rather than a default — claiming the instance is the next five minutes.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.