Skip to content

Self-host MailySend on a plain Node server

The same application, without Cloudflare in front of it: a Node build behind nginx, run under a process manager, with the platform bindings pointed somewhere else.

DeployAdvanced14 min readUpdated

01What is different without Workers

Less than you would expect. The build emits a Worker-shaped module — { fetch, queue, email, scheduled } — because that is what the whole codebase is written against, and the Node listener’s entire job is to open a socket and turn Node streams into Request and Response.

TL;DR

Three platform capabilities resolve differently off Workers — Workflows, Analytics Engine and the send_email binding — and each one is answered by an adapter rather than switched off.

CLOUDFLARE_CAPABILITIES
  • workflows: true — automations run on the Workflows engine
  • analyticsEngine: true
  • emailBinding: true
  • longLivedProcess: false — 30s CPU per request
NODE_CAPABILITIES
  • workflows: false — the same step interpreter, driven by the scheduler
  • analyticsEngine: false — a local table of the same shape
  • emailBinding: false — Cloudflare Email Service over REST
  • longLivedProcess: true, with a self-imposed maxTaskMs of fifteen minutes

The seam is one package, not a runtime branch. Everything platform-shaped — the database, the queue, the blob store, the key-value cache — is reached through @mailysend/platform rather than imported directly, which is what makes “the Node target runs the same code” checkable by reading rather than by trusting. node-server.mjs is deliberately one file with no dependencies for the same reason, and it does exactly two things worth knowing about: prerendered HTML and hashed assets are served straight from .output/client without touching the application, and everything else goes to the same fetch the Workers runtime would have called.

What the adapters do instead

Platform pieceOn NodeThe difference that matters
QueuesA SQLite table and a poller — a visible_at column and a transactional claim.At-least-once delivery, per-message ack and retry, delayed visibility, batching and a dead-letter path after a bounded number of attempts are all reproduced.
Durable ObjectsIn-process actors.The constraint that shapes the whole deployment: one process, in fork mode, forever. See the next section for why that is not a preference.
AutomationsThe scheduler, on the same step interpreter.Behaviour matches; durability is weaker, in that a crash mid-step replays from the last write rather than resuming inside it. No 30-second CPU ceiling, so the cap becomes a fifteen-minute watchdog.
Analytics EngineA local table with the same shape — one index, blobs, doubles.Per-event analytics are kept rather than stubbed out, and the dashboard queries whichever of the two is present. The three-month retention does not apply; the R2 archive still runs.
Cloudflare transportThe REST endpoint, which needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.The transport is not gone — the binding is.

If you would rather not have a Cloudflare account in the picture at all, the other three transports — SES, Resend and generic SMTP — need nothing from that last row; the transport guide has the real ceilings for each.

02Build the Node target

Two commands.

TL;DR

One environment variable has to be present during the build rather than at run time, and that is the source of the most confusing failure on this page.

$ pnpm install --frozen-lockfile
$ pnpm build:node
Prerendered 42 pages
llms: wrote .output/client/sitemap.xml
BuildMS_TARGETWrites to
pnpm build:nodenode.output
pnpm build:cfcloudflare.output-cf
the dev servera third directory again

So a running dev server and a production build never contend for the same files.

Prerendering is not decoration. Every public page — marketing, docs and every guide — is rendered to static HTML at build time from one list that also drives sitemap.xml, llms.txt and robots.txt, so those four cannot drift apart. Link crawling and auto-discovery are both off, deliberately: crawling minted a sitemap URL per deep anchor, and auto-discovery swept the per-user dashboard screens into the static output and the sitemap, where they must never be cached or indexed. A prerender error fails the build, because the alternative — the setting that let it pass — once produced “Prerendered 0 pages”, a zero exit code, and a deploy with no static HTML at all.

This is the bug that hid the longest. MS_PUBLIC_URL lives in the .env the process manager loads at run time, and nothing put it into the shell during pnpm build:node. So the sitemap host resolved to undefined, the sitemap was silently disabled, and robots.txt advertised a URL that returned 404 for the life of the site. A build that still has no host now ships a robots.txt with no Sitemap: line at all, rather than pointing crawlers at a page that is not there — and only a build with MS_LANDING=marketing may publish mailysend.com as its canonical host, because a sitemap naming somebody else’s host is worse than no sitemap.

You do not need a real signing secret to build. The prerendering server wants one, so the build generates a random build-scoped value when MS_SECRET is unset: nothing prerendered is signed, and the value dies with the process. A real secret in the environment still wins.

03Run it behind nginx

One process on a loopback port, nginx in front of it, and a process manager to restart it. Nothing more exotic is needed, and anything more exotic has a cost you should be choosing deliberately.

TL;DR

The live deployment at mailysend.com is exactly this: a checkout at /srv/mailysend, one PM2 process on 127.0.0.1:8917, nginx terminating TLS.

NGINX
Terminates TLS
mail.yourdomain.com
PROXY
127.0.0.1:8917
loopback only
NODE
node-server.mjs
fork, one instance
PM2
Restarts and drains
ecosystem.config.cjs
1 · Start it
$ pm2 start ecosystem.config.cjs
mailysend │ fork │ online │ 127.0.0.1:8917
$ pm2 save && pm2 startup

The committed ecosystem.config.cjs runs apps/app/node-server.mjs — not .output/server/server.js, which is the Worker-shaped module and has no socket — with node_args: '--enable-source-maps --env-file-if-exists=.env'. The if-exists half matters: the same command works on a box that has not been configured yet, which makes a first boot legible rather than a crash loop. If there is no build on disk, the listener says so and names the command to run instead of failing with a module-not-found stack.

2 · Put nginx in front
map $http_x_forwarded_proto $ms_forwarded_proto {
    ""      $scheme;
    default $http_x_forwarded_proto;
}

server {
  server_name mail.yourdomain.com;
  client_max_body_size 200M;

  location / {
    proxy_pass http://127.0.0.1:8917;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $ms_forwarded_proto;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

X-Forwarded-Proto is load-bearing rather than tidy: without it every absolute URL the app mints — tracking pixels, unsubscribe links, canonical tags — comes out http:// on an https site. client_max_body_size is what lets an attachment upload through, and nginx’s 1 MB default rejects it with a 413 before the application ever sees it.

In ecosystem.config.cjsValueThe question it answers
kill_timeout12000Gives the runtime time to finish a batch and close its SQLite handles.
max_memory_restart900MTurns a leak into a restart rather than a dead box.
min_uptime / max_restarts20s / 10Stops a misconfigured instance from restarting forever while looking healthy in pm2 list.

04The environment variables that matter

Five variables carry the weight.

TL;DR

The column that matters is the middle one: a variable needed at build time and set only at run time is silently ignored, which is a failure with no error message — and was, for the whole life of this site’s sitemap.

variableneeded atwhat it does
MS_SECRETrunSigns tracking, unsubscribe and reply tokens. Unset, a 32-byte value is generated on first boot and persisted, so links survive restarts. Set but shorter than 32 characters is refused outright — that is a mistake rather than a choice.
MS_PUBLIC_URLrun + buildThe canonical origin. Tracking links use it at run time; the sitemap is generated from it at build time. A build with no host ships no sitemap rather than a wrong one.
MS_LANDINGrun + buildmarketing serves the public site at /; app redirects to the dashboard, or to /setup while unclaimed. At build time it also decides whether this build may publish mailysend.com as its canonical host.
MS_TARGETbuildnode or cloudflare. Selects the platform adapters and the output directory; pnpm build:node sets it for you.
MS_DATA_DIRrunWhere SQLite and blobs live on disk — mailysend.db, plus a blobs/ directory. Back this up; it is the whole of your data.

Where each one lives

FileCommitted?What belongs in it
ecosystem.config.cjsYesAnything that is not a secret and describes how this box runs the app: NODE_ENV, PORT, HOST, MS_MODE, MS_DATA_DIR and MS_LANDING.
/srv/mailysend/.envNoAnything secret, and anything the build also needs. Loaded at run time by --env-file-if-exists=.env and at build time by the loader described in the previous section.

The split is not arbitrary — it is “does the build need this, and would you mind it being in git”.

# /srv/mailysend/.env — read by the runtime and by the build
MS_PUBLIC_URL=https://mail.yourdomain.com
MS_SECRET=…                 # openssl rand -hex 32
SES_REGION=…                # only needed before anyone has signed in

That is the practical consequence of the build reading .env: setting MS_PUBLIC_URL in one file is what makes sitemap.xml exist for a self-hosted deployment, and it does so without a build environment that has to be kept in sync with a runtime one by hand.

Back up the data directory, not the checkout. MS_DATA_DIR holds mailysend.db — contacts, messages, events, settings, the queue spool, the local analytics table — and a blobs/ directory with raw inbound MIME and attachments. Everything else on the box is reproducible from git.

05Upgrading without downtime you will notice

Pull, build, reload. There is no migrate step to remember.

TL;DR

That is deliberate rather than an omission — but the ordering of the other three is still what makes a rollback possible rather than theoretical.

$ cp -a .data .data.bak
$ git fetch && git checkout <tag>
$ pnpm install --frozen-lockfile && pnpm build:node
$ pm2 reload mailysend

Migrations run themselves. The first touch after a restart runs any migration the _migrations table does not already list, then ensures the workspace exists — both idempotent, so the cost on every later cold start is one indexed lookup. This is the same code path the Cloudflare deployment runs on its first request, which is why there is one migration set for D1 and node:sqlite rather than two that can disagree.

pm2 reload drains rather than kills. A hard kill mid-batch is survivable — a send attempt holds a lease, the lease expires, and the work is picked up again — but a clean drain is cheaper and does not produce a minute of confusing telemetry. With one fork-mode process there is no zero-downtime handover to be had, so expect a short gap; nginx will return 502 during it, and that is the honest behaviour rather than a queue of requests waiting on a process that may not come back.

Then checkWhat it answersCost
GET /v1/healthoperational with a version number and the result of a real query against the database, or degraded with the driver’s own error and a 503.One unauthenticated request
GET /v1/instanceWhat the deployment believes about itself — claimed or not, which sign-in doors are open, how many domains are verified.One unauthenticated request

In that order. Both are cheaper than reading logs/mailysend.err.log and much cheaper than reproducing the failure.

What just happened

You have the same application running under a process manager behind nginx, reading its configuration from one .env file, with its data in a directory you can back up. Everything the product does — broadcasts, automations, inbound, analytics — runs on the platform adapters, with three capabilities that resolve differently off Workers and are named below rather than left to be discovered.

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.