On the morning of Saturday, 3 October 2026, a message sent from an outside mailbox landed in an inbox on a server that cannot receive mail.
Not will not. Cannot. The machine holding that message has no mail port open to the internet. Port 25 — the door every mail server on earth keeps propped open to accept delivery — is closed on that host and has never been opened. A sending server cannot connect to it. Nothing on the box is listening.
The message arrived anyway, in under a second, correctly addressed and correctly filed.
It came in over HTTPS, on the same port that serves the admin page, carried by a small program running inside Cloudflare's network rather than by a connection from the sender's machine. The sender's server talked to Cloudflare, which does keep port 25 open, in a few hundred cities. Cloudflare handed the message to the program. The program pushed it into the mailbox through an API call.
The front door of email has been bricked up for a decade. This is a side entrance, and it was always there.
The Door That Closed
Self-hosting email is the one piece of infrastructure the internet agrees you should not run yourself. The advice is close to unanimous, and the most-cited version of it comes from someone who did run it, well, for a very long time.
In 2022 Carlos Fenollosa published a post explaining that after twenty-three years of hosting his own email he had thrown in the towel.[1] His complaint was not that the software was difficult. The software was fine. The problem was that his mail stopped arriving — silently dropped or filed as spam by Microsoft and Google, from a correctly configured server with clean DNS, a clean IP, and two decades of history. There was no error to debug and no one to appeal to.
Three things closed the door, and only one of them is technical.
The first is the ports. Outbound port 25 is blocked by default across the major cloud providers and most residential ISPs, a near-universal anti-spam measure that also makes a mail server on a cheap VPS unable to send mail at all without an exception.[2]
The second is reputation, which cannot be configured. A new address has no sending history, and no amount of correct DNS substitutes for one. The large receivers decide whether to believe you based on behaviour they have observed over months.
The third arrived in February 2024, when Google and Yahoo began requiring SPF, DKIM and DMARC from bulk senders, along with one-click unsubscribe and a spam-complaint rate held under a published threshold.[3] This one is widely misread as another nail in the coffin. It is closer to the opposite: it raised the floor on authentication, which is the part a careful operator can actually control. The standards involved — SPF,[4] DKIM[5] and DMARC[6] — are open, well documented, and entirely achievable for a domain handling a dozen messages a day.
So the protocol never closed. SMTP is still an open standard and anyone may implement it. What closed was the operational position: the ability to run a machine that other machines will connect to, and whose mail other machines will believe.
Which raises a question the give-up posts tend not to ask. If the two genuinely hard problems are accepting connections and earning sending reputation — and neither of those is where your data lives — why are you still trying to solve them yourself?
What Arrives, and How
The architecture that follows is the answer to that question. It gives away the two hard problems and keeps everything else.
┌───────────────────────────┐
sender ──SMTP──► │ Cloudflare Email Routing │ port 25, never yours
└─────────────┬─────────────┘
│ routing rules
▼
Worker: inbound-mail
│ JMAP import
▼
Stalwart on mail.sage.is
(one container, no open port)
│
┌────────────────┴────────────────┐
▼ ▼
Nextcloud Mail over IMAP/SMTP relay over SMTP
(next.startr.cloud, private net) (SES or Resend)
│ │
└────────────► you ◄──────────────┘
Cloudflare Email Routing accepts the connection. It is free, it runs on Cloudflare's edge, and it can hand a received message to a Worker rather than merely forwarding it to another address.[7] That last capability is the whole trick: instead of bouncing your mail to Gmail, the routing layer runs your code on it.
The Worker is about sixty lines. It reads the incoming message, authenticates to the mail server as a dedicated ingest account, and imports the raw message over JMAP — a modern JSON API for mail, which Stalwart implements natively alongside IMAP and SMTP.[8] Stalwart is an open-source mail server that runs here as a single container behind the same reverse proxy as everything else on the host. No mail port is mapped. The only thing exposed is HTTPS.

A mail sorting room, Washington D.C., early twentieth century. The letter has already arrived at the building; the work pictured is deciding which pigeonhole it belongs in. Cloudflare's Worker does this job in about sixty lines. National Photo Company Collection, Library of Congress. Public domain.
If the import fails — wrong password, server down, quota exceeded — the Worker forwards the message to a fallback mailbox you own. A failure degrades to ordinary forwarding rather than to a bounce. That matters more than it sounds: the failure mode of a clever mail setup is usually silent loss, and this one is designed so the worst case is that a message arrives somewhere less convenient.
Inbound messages are capped at 25 MiB at the Cloudflare edge, before your own server's limits apply.[9] Test a large attachment before you cut over, because the cap is theirs, not yours, and you cannot raise it.
Sovereignty was never about running every part yourself. It is about being able to replace any part without asking permission.
The Part That Leaves
Outbound mail is where self-hosting actually dies, and the fix is to not do it.
Stalwart does not deliver to the internet. Every message not destined for a local domain goes to a relay — Amazon SES in this setup, with Resend as a fallback — over an authenticated SMTP connection. Nothing leaves from the host's own address, which means the host's address never needs a sending reputation, never needs port 25 unblocked, and never appears in a receiving server's decision about whether to trust the mail.

The Rohrpostamt in Berlin, c. 1905. Messages arrived in canisters through pressurised tubes, were sorted, and were pushed onward through the next pipe. The sender never touched the network; the network never touched the sender. From Rudolf Loës, "Der Weltverkehr und seine Mittel." Public domain.
The relay signs nothing on your behalf that matters. DKIM signing happens on your server, with d=sage.is exactly, because the domain's DMARC policy is set to strict alignment and a signature from the relay's own domain would not satisfy it. The relay is a transport, not an identity. Swapping SES for Resend, or for a box in a different jurisdiction, is a route change and a DNS record — not a migration.
This is the asymmetry that makes the whole design work. Receiving is hard because other people's servers have to reach you. Sending is hard because other people's servers have to trust you. Both problems live at the edge of the network, and neither of them is your mail. Hand both to providers you can replace, and what remains — the messages, the folders, the addresses, the search index, the disk — is yours in a way it has not been since people stopped running their own servers.
What Broke
The architecture is simple. The two days of work were not. Most of what follows cost an hour or more to diagnose, and none of it is in the obvious documentation.
The worst of them was a setting called proxyTrustedNetworks, which sounds exactly like the thing you want when your mail server sits behind a reverse proxy. It is not. It controls PROXY-protocol trust, not header trust. Set it to the overlay network range — the apparently correct value — and the listener begins demanding a binary protocol header that the proxy never sends. Every request fails with "invalid proxy header," the health check reads 502, and nothing in the error text points at the setting that caused it. The correct configuration is to leave it empty and turn on useXForwarded instead, which tells the server to read the client address from the forwarded header. Without that second setting every client appears to arrive from the proxy's address, which means a single ban rule locks out everyone at once.
The second trap is a lifecycle problem. Stalwart's bootstrap serves on port 8080, but only while the server is in bootstrap or recovery mode, and bootstrap creates no permanent listeners of its own. Add the real listener and change the port in the same sitting, before any restart, or the application comes back unreachable and has to be coaxed into recovery mode to fix it. The listener must also bind the IPv4 wildcard rather than the IPv6 [::] form, which works locally and fails inside a Swarm container.
The third cost a full afternoon and is a Nextcloud problem, not a mail one. Nextcloud Mail can provision accounts automatically, which seems like the right way to set up a dozen mailboxes. But every request to a provisioned account re-provisions it, and re-provisioning strips any alias the provisioning data did not supply — and only LDAP can supply them. The create call returns 201, everything looks correct, and the alias is gone by the next page load. Shared send-as addresses therefore require hand-made accounts, created as each person through the Impersonate app. Nextcloud also refuses to create an account pointing at a private IMAP host unless the container carries NC_allow_local_remote_servers=true, and the error it logs — "violates local access rules" — appears nowhere near the setting that fixes it.
A fourth is quieter and will bite later: after a relay or listener change, the delivery queue keeps the route it loaded at start. Bump the configuration revision and re-run, or the next send goes to the old address with the new password and fails with a 535 that looks like a credentials problem and is not.
The full list is in the runbook below. It runs to twenty-odd items, and the honest summary is that the architecture took an hour to understand and two days to make stay up.
What It Costs
Cloudflare is free here. Email Routing, the Worker, DNS and the proxy all sit within the free tier, and the Worker moves to the paid plan only if its CPU time per invocation exceeds the free limit — five dollars a month if it does.[10]
The relay costs cents. A few thousand messages a month lands under a dollar, and the sandbox tier is free while you test, though it restricts you to verified recipients and a daily cap until production access is granted. Hosting is one container and one volume on a machine you are already paying for.
The honest figure is therefore close to zero marginal cost and two days of competent engineering time, which is the part nobody puts in the comparison table. If your time is worth anything, the first year costs more than a mailbox subscription. The second year does not, and the data was never the subscription's to hold.
What You Are Still Trusting
This is not independence, and it would be dishonest to sell it as such.
Cloudflare sees every inbound message in the clear. The relay sees every outbound one. Both of those are real exposures, and anyone whose threat model includes a large American infrastructure company reading their mail should stop reading here, because this architecture does not solve that problem and was not built to.
What it changes is the number of parties and the cost of leaving. Before, a single provider held the messages, the addresses, the archive and the identity, and moving meant exporting everything and updating every account you own. Now two providers each see mail in transit, neither stores it, and replacing either is an afternoon's work. The archive sits on a disk in a container you control. The addresses resolve through DNS you administer. Nothing has to be exported, because nothing left.
That is a smaller claim than "run your own mail server," and it is a true one. The maximalist version — accepting connections on your own port 25, earning your own sending reputation, arguing with Microsoft's postmaster team — is still available, still difficult, and still mostly a way to discover that your mail is not arriving. The version described here concedes those two fights deliberately in order to win the one that was always the point.
The Message That Arrived
The test message that landed on Saturday morning went out from an outside mailbox, because a message sent from the server to a domain the server already holds never leaves the building and proves nothing. It travelled to Cloudflare, through a Worker, into a container, and onto a disk. The reply went out through a relay and arrived at a Gmail account with SPF, DKIM and DMARC all passing and the signature reading d=sage.is.
The server it lives on still cannot receive mail. The firewall is closed, no mail port is mapped, and nothing on the host is listening for a connection from the internet. The mail arrives regardless, because the only thing that ever needed to be reachable was the edge — and the edge was never yours to begin with.
The pneumatic post under Paris carried letters through pressurised tubes for roughly a century and shut down in 1984, not because the tubes failed but because the network around them changed.[11] The letters were never in the tubes for long. They were in the building at either end.
The Runbook
What follows is the exact sequence two founders ran on 3 October 2026, with the checks at each step. Steps marked [WE] are scripted; steps marked [MANUALLY] need a human in a browser.
The commands call three small Python scripts written for the job: one for Cloudflare (DNS, routing rules, the switch), one for Stalwart (its whole configuration over the JMAP management API, since the image has no CLI), and one for Nextcloud (users, Mail accounts, aliases). Each prints what it sends and does nothing on --dry-run. They live in our Trellis repository and will be linked here when that repository is public. Until then, every call maps to one documented API request.
Day 0 — DNS
-
[MANUALLY] Build a secrets file outside the repo and source it before every script step. It holds a Cloudflare API token with Zone Read, DNS Edit, Zone Settings Edit and Email Routing Rules Edit on your zones, plus Email Routing Addresses Edit and Workers Scripts Edit on the account, and the relay credentials. Nothing in it is typed into a browser.
set -a; . ~/.secrets/mail.env; set +aCheck:
crm/deploy/mail/cloudflare.py zoneprints the zone and the plan. -
[WE] Widen the apex SPF in place, before its final
all. Your old provider's include stays for its 30 days; Cloudflare's and your relay's are added.crm/deploy/mail/cloudflare.py apex-spfCheck:
dig +short TXT sage.isshows one SPF record, never two. -
[WE] Verify the sending domain with Easy DKIM, set the custom MAIL FROM, and publish every record the relay prints.
SES_DOMAIN=sage.is crm/deploy/mail/ses.sh identity SES_DOMAIN=sage.is crm/deploy/mail/ses.sh mail-from crm/deploy/mail/cloudflare.py dns-upsertCheck:
crm/deploy/mail/ses.sh statusshows the domain verified, DKIM success and the MAIL FROM set. -
[WE] Append the DMARC reporting address.
p=quarantineandadkim=sstay.crm/deploy/mail/cloudflare.py apex-rua --rua mailto:[email protected]Check:
dig +short TXT _dmarc.sage.isshows your reporting address. -
[WE] Add the destination the Worker forwards to when an import fails.
crm/deploy/mail/cloudflare.py destination-add --email <a-mailbox-you-own>Check:
crm/deploy/mail/cloudflare.py destination-listlists it. -
[MANUALLY] Click the verification link Cloudflare mails to that address. An unverified destination refuses the forward.
Check: the dashboard shows the destination verified. -
[MANUALLY] Leave Workers on the Free plan until the logs prove you need more.
Check: the first day of logs shows noEXCEEDED_CPU.
Day 1 — The Server
-
[WE] Create the CapRover app: both volumes, the env, the A record
mail.sage.isproxied to the host, HTTPS, force-SSL.make caprover_up INSTANCE=mail DRY=1 make caprover_up INSTANCE=mailCheck:
https://mail.sage.is/healthz/liveanswers. -
[WE] Deploy the image by digest. No Make target deploys a vendor image, and
make caprover_deploybuilds HEAD from a tarball — wrong here.docker buildx imagetools inspect stalwartlabs/stalwart:v0.16.24 cr-deploy deploy-image mail stalwartlabs/stalwart@sha256:<digest> --health https://mail.sage.is/healthz/liveCheck:
cr-deploy appsshows the digest running, not a tag. -
[WE] Run bootstrap, the wizard's own call. Requesting no TLS certificate is correct: CapRover terminates TLS and owns the ACME path. DKIM keys are generated and the database lands in the volume.
crm/deploy/mail/stalwart.py bootstrap --hostname mail.sage.is --domain sage.is --admin-password <yours>Check: the call returns and prints the random admin secret once.
-
[WE] In the same sitting, before any restart, add a permanent plain HTTP listener on 8081 bound to the IPv4 wildcard. Bootstrap creates no listeners of its own, and 8080 serves only while the server is in bootstrap or recovery mode. If the server has already restarted without one, set
STALWART_RECOVERY_MODE=1in the app's environment with port 8080, run this, then revert.crm/deploy/mail/stalwart.py listener-http --port 8081Check: the listener reads
0.0.0.0:8081in the settings. -
[WE] Point the app at 8081:
port: 8080becomes8081, then restart.make caprover_up INSTANCE=mailCheck:
https://mail.sage.is/adminanswers and the image healthcheck passes on 8081. -
[WE] Keep
proxyTrustedNetworksempty and turn onuseXForwardedon the Http settings, so Stalwart reads the client address from the forwarded header. The command takes no network list on purpose: that setting is PROXY-protocol trust, and filling it broke the proxy.crm/deploy/mail/stalwart.py trusted-networksCheck: the Http settings in
/adminshowuseXForwardedon and the trusted-network list empty. -
[WE] Prove the admin login, then remove the recovery admin from the env.
export [email protected] crm/deploy/mail/stalwart.py session make caprover_up INSTANCE=mailCheck: the session works after the restart and the recovery admin is gone from the app's environment.
Day 2 — Accounts and Routes
Every command here is a management call over the API. The /admin page shows the result.
-
[WE] Create the domain: automatic DKIM, DNS management off. Your own DNS management would publish an MX that collides with Email Routing.
crm/deploy/mail/stalwart.py domain --name sage.is crm/deploy/mail/stalwart.py dkim-records --name sage.isCheck: two TXT records print, and
cloudflare.py dns-upsertpublishes them beside your existing selectors. -
[MANUALLY] Settle the roster: one login per person, each public address equal to the login. The client's From address is the account's own address, so the two must match. Shared addresses such as
hello@belong to a group. Write the list down; day 3 turns each address into one routing rule.
Check: every address you can receive at is on the list, with an owner. -
[WE] Create the ingest role, the accounts, the group
hellowith its members, and the catch-all, then confirm the sender check. A member may send as any of the group's addresses by default; the group's own inbox is what members see as a shared folder.crm/deploy/mail/stalwart.py role-ingest crm/deploy/mail/stalwart.py account --name ingest --domain sage.is --role ingest --password <primary> crm/deploy/mail/stalwart.py account --name <login> --domain sage.is --password <first-sign-in> crm/deploy/mail/stalwart.py group --name hello --domain sage.is --member <a> --member <b> crm/deploy/mail/stalwart.py catch-all --domain sage.is --to [email protected] crm/deploy/mail/stalwart.py must-match-sender --showCheck:
/adminlists each account and the group, and mail to a made-up address lands inhello@. -
[WE] Raise the import quota for a day of attachments.
crm/deploy/mail/stalwart.py jmap-limitsCheck: the upload quota reads 500,000,000 bytes per user per period.
-
[WE] Add the relay route and send everything that is not local through it.
crm/deploy/mail/stalwart.py relayCheck:
/adminshows the relay route and the outbound strategy keeps local domains local. -
[WE] Sign authenticated mail from the local domain, with
d=sage.isexactly.crm/deploy/mail/stalwart.py sign-policyCheck: a test send shows
d=sage.isin the signature. -
[WE] Print what the zone still needs and publish it.
crm/deploy/mail/cloudflare.py records-for-mailCheck: every mail record is in place before day 5.
Day 3 — The Worker
Email Routing already runs on a test subdomain, so the proof happens there. Run wrangler from the repo root through a container; no local install. Define it once:
wrangler() { docker run --rm -v "$PWD/crm/deploy/cloudflare:/w" -w /w -e CLOUDFLARE_API_TOKEN -e CLOUDFLARE_ACCOUNT_ID node:22-alpine npx -y wrangler@4 "$@"; }
-
[WE] Add the test subdomain to the server, create a probe account, and publish that zone's DKIM.
crm/deploy/mail/stalwart.py domain --name startr.cloud crm/deploy/mail/stalwart.py account --name probe --domain startr.cloud --password <x> crm/deploy/mail/cloudflare.py --zone startr.cloud dns-upsertCheck: the probe account signs in.
-
[WE] Put the ingest account's primary password in the Worker as a secret, then deploy.
wrangler secret put STALWART_INGEST_PASSWORD wrangler deployCheck: the deploy prints a new version.
-
[WE] Route the probe address to the Worker.
crm/deploy/mail/cloudflare.py --zone startr.cloud rule-upsert --to [email protected] --worker inbound-mailCheck:
crm/deploy/mail/cloudflare.py --zone startr.cloud rule-listshows the rule. -
[MANUALLY] Send one message from an outside mailbox to the probe address. It must come from outside: a message sent from the server itself never leaves it.
Check: the Worker log shows the import and the message sits in Stalwart. -
[WE] Prove the fallback: set a wrong ingest secret once, send again, then put the right one back.
wrangler secret put STALWART_INGEST_PASSWORDCheck: the message lands at your fallback mailbox; restore the secret and confirm the next message imports.
-
[WE] Add the apex rules, dormant until the switch: one rule per address, plus the catch-all into the group.
crm/deploy/mail/cloudflare.py rule-upsert --to <address> --worker inbound-mail crm/deploy/mail/cloudflare.py rule-upsert --catch-all --action worker --value inbound-mailCheck:
rule-listshows one rule per address plus the catch-all, and none fires while the old MX is live.
Day 4 — Clients, Shared Mailboxes, Send-As
-
[MANUALLY] Each person signs in at
https://mail.sage.is/with the first sign-in password, changes it, and makes app passwords in the self-service portal when a client needs one. Admins cannot make them.
Check: each person reaches their own inbox. -
[WE] Add a plain IMAP listener on the private network and allow plain-text auth. No mail port is mapped on the app, so it never reaches the internet.
crm/deploy/mail/stalwart.py listener-imap-overlay --port 143Check: port 143 answers on the overlay host name and not from outside.
-
[MANUALLY] In Nextcloud, set each user's e-mail field to their server login — one and the same value — and make an admin app password. The uid is never used.
# the secrets file, outside the repo NEXTCLOUD_USER=... NEXTCLOUD_APP_PASSWORD=...Check: the e-mail field equals the login for every user.
-
[WE] See who the sync would take, set the e-mail fields, create a server account for each, then make the impersonator: a role with
authenticateandimpersonateand nothing else, and one account that carries it. Its primary password is the master password every Nextcloud Mail account signs in with, as<person>@sage.is%[email protected]. The session then runs on the person's own permissions, not the impersonator's.crm/deploy/mail/nextcloud.py users crm/deploy/mail/nextcloud.py email --uid <uid> --email <login>@sage.is crm/deploy/mail/nextcloud.py sync crm/deploy/mail/stalwart.py role-impersonator crm/deploy/mail/stalwart.py account --name nextcloud --domain sage.is --role client-impersonator --password <master>Check:
curl -u '<login>@sage.is%[email protected]:<master>' https://mail.sage.is/jmap/sessionanswers 200. -
[MANUALLY] Two settings on Nextcloud itself, once: install the Impersonate app from the app store, and give the Nextcloud container the environment variable
NC_allow_local_remote_servers=true. The first lets the admin create a Mail account as another user. The second lifts the rule that refuses a private IMAP host when an account is created.# CapRover: app startr-next-cloud, environment, NC_allow_local_remote_servers = trueCheck: Nextcloud's log shows no "violates local access rules" line on the next step.
-
[WE] Make each person's Mail account by hand, as the person, with their aliases, then retire provisioning if you tried it. A provisioned account cannot keep an alias, so the hand-made path is the one that holds. The command also pins the account's own Sent, Drafts and Trash.
crm/deploy/mail/nextcloud.py account --uid <uid> --login <login> --email <login>@sage.is --name "<Full Name>" --alias "[email protected]=<Group name>" crm/deploy/mail/nextcloud.py unprovisionCheck: the alias survives the next request and
hello@shows in the From menu. -
[WE] Share each inbox and Sent both ways, and grant cross send-as, so each founder may reply as the other.
crm/deploy/mail/stalwart.py share-inbox --owner <a> --with <b> --domain sage.is crm/deploy/mail/stalwart.py share-inbox --role sent --owner <a> --with <b> --domain sage.is crm/deploy/mail/stalwart.py send-as --user <a>@sage.is --as <b>@sage.isCheck:
crm/deploy/mail/stalwart.py send-as --listshows the grant and the other's Sent appears under Shared Folders. -
[MANUALLY] Any other app on the same private network reads a mailbox the same way: host
srv-captain--mail, port 143, TLS off, and an app password made by that person in Stalwart's self-service portal.
Check: the app shows a fresh poll and no error.
Day 5 — The Switch and the Rollback
Cloudflare locks the MX when routing is enabled, so there is no two-server overlap. After the switch the old provider receives nothing new and stays readable for its retention window.
-
[MANUALLY] Run the pre-checks: every account exists and each person has signed in; the relay sends to a verified recipient; the probe is green; Nextcloud Mail reads a mailbox.
crm/deploy/mail/cloudflare.py routing-statusCheck: all four are true.
-
[WE] Write the snapshot of the current MX and routing state.
crm/deploy/mail/cloudflare.py routing-snapshotCheck: keep the snapshot path; it is the rollback.
-
[MANUALLY] Rehearse the switch, then run it on the owner's word. The snapshot must be younger than an hour.
crm/deploy/mail/cloudflare.py --dry-run routing-enable-apex --snapshot FILE crm/deploy/mail/cloudflare.py routing-enable-apex --snapshot FILE --yes-switch-mxCheck: the closing line reads
other-mx=0; any other count means the old MX records are still there and senders may keep using them. -
[WE] Verify the records five minutes later.
dig +short TXT sage.is dig +short MX sage.isCheck: one SPF record, and the MX points at the routing host names.
-
[MANUALLY] Send from an outside mailbox to each address, reply from Nextcloud Mail, and read the headers at the receiver.
Check: SPF, DKIMd=sage.isand DMARC all pass. -
[WE] Roll back if any of that fails.
crm/deploy/mail/cloudflare.py routing-disable-apex --snapshot FILE --yes-restore-mxCheck: the old MX is back within the TTL and mail flows again.
Every Trap, In One Place
- The apex trap. Clicking Enable or Fix records in the Email Routing dashboard replaces and locks the old MX ahead of time. The switch is the script on day 5, nothing else.
- A second SPF record. Cloudflare may add its own SPF TXT on the apex when routing is enabled, and two SPF records fail SPF everywhere. The script fails loudly on two; merge back to one.
- The catch-all. Mail to an address missing from the Worker's list is filed into the shared group through the member retry; if that retry fails too, it forwards to your fallback mailbox.
- Shared folders in Nextcloud Mail. The group's inbox shows under Shared Folders, and Mail 5.6 can pick a shared Sent as an account's default Sent folder. The account command pins the account's own Sent, Drafts and Trash.
- The 8080 lifecycle. It serves until bootstrap ends; the 8081 listener and the port change must follow in the same sitting or the app is unreachable.
proxyTrustedNetworksis PROXY-protocol trust, not header trust. Set to the overlay range, it makes the listener demand a header your proxy never sends, and every request fails with "invalid proxy header" while health reads 502. It stays empty.- Client addresses. Without
useXForwardedon the Http settings every client is the proxy's address, and one ban cuts everyone off. - The listener's bind must be the IPv4 wildcard, not the IPv6
[::], in a Swarm container. - Impersonation needs the primary password. An app password answers 401.
uploadQuota. At the 50 MB default, a busy day of attachments stops the import.- Workers CPU. An email handler can pass the Free plan's limit and the message bounces; move to Paid when the logs show
EXCEEDED_CPU. - DNS management collision. The server's own DNS management publishes an MX beside Email Routing's.
- DKIM alignment. The apex DMARC is
adkim=s, so the signature must bed=sage.isexactly. - Overlay IMAP TLS. Plain auth belongs on a listener bound for the private network only; no mail port is mapped on the app.
- Pin the image.
v0.16.24by digest;v0.16andlatestmove weekly. - Route changes need a restart. The queue keeps the relay route it loaded at start. After a relay or listener change, bump the config revision and re-run ensure, or the next delivery goes to the old address with the new password and fails with 535.
alreadyExistson retry. A second create of a domain, account or route answersalreadyExists; that is done, not failed.- Ingest permissions. The ingest role needs
authenticate,impersonateand the JMAP import permissions. With only the import permissions the session endpoint answers 403 and every inbound message falls through to the fallback. - Group replies. A reply sent as a group address lands in the sender's own Sent folder; Stalwart has no shared Sent yet.
- Wrangler does not reconcile routing rules. Its address list calls an account-level endpoint the deploy token lacks, so the rules are the script's job.
- Nextcloud's
RemoteHostValidatorrefuses a private IMAP host when an account is created or its host edited ("Host … violates local access rules"; the provisioning path never checks), so the Nextcloud app carriesNC_allow_local_remote_servers=true. - Provisioned Mail accounts lose aliases. Every request re-provisions the account and removes any alias the provisioning did not supply, and only LDAP supplies them. The create route answers 201 and the alias is gone by the next request. Send-as needs hand-made accounts, created as the person through the Impersonate app.
- The folder list refreshes every two hours. Nextcloud reads the IMAP folder list at most that often, with no force route, so a new share appears late for an account that already synced.
- A group cannot be impersonated. It answers 403.
- The Mail app keeps a provisioning master password in clear text in its provisioning table; hand-made accounts store it encrypted per account. Either way it is domain-wide mailbox access: rotate it in Stalwart and re-enter it, and keep the plain listeners on the private network.
- The relay can pause you. A provider refused a send at DATA with 554 "Sending paused for this account" until the appeal was answered. A fallback relay keeps mail moving.
- The sandbox. The relay sends to verified recipients only, with a daily cap, until production access is granted — and production access is granted per region.
- Inbound size caps. 25 MiB at Cloudflare, then your server's upload limit, with the proxy above both. Test a large message before the switch.
- Inbound tests must come from outside. A message sent from the server to a domain the server holds never leaves it.
- If the enable leaves the old MX records beside Cloudflare's, senders keep choosing the old priority and the switch has not happened; the script prints
other-mx=when it sees them.
Footnotes
Disclosure: Sage.is is a product of Startr LLC. The infrastructure described here runs the company's own mail; Cloudflare, Amazon SES, Resend, Stalwart and Nextcloud are named because they are what we deployed, and none of them is a sponsor or a commercial relationship beyond ordinary paid or free-tier use. This article was researched and drafted using AI tools within a structured workflow with editorial review, and the deployment it describes was carried out by Alexander Somma and Isabelle Plante on 3 October 2026.
Carlos Fenollosa, "After self-hosting my email for twenty-three years I have thrown in the towel" (2022). cfenollosa.com ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Pending verification. ↩︎
Sage.is