Every guide says don’t self-host email, and it’s worth being precise about where they’re right. It’s not receiving; receiving is easy. It’s not the software; modern servers are excellent. It’s IP reputation. The big providers judge your mail substantially by where it comes from, and a residential IP or a cheap VPS comes from nowhere good. One major provider blocklists entire hosting ranges wholesale. I’ve read my own bounce messages telling me my IP was rejected for the company it keeps. No amount of correct SPF and DKIM repairs a reputation you were never allowed to build.

The guides’ conclusion (“so don’t”) is where I get off the bus. The right conclusion: design so your own IP’s reputation never matters. Accept that constraint up front and self-hosted mail stops being a heroic struggle and becomes one more service with one weird dependency. I waited a long time to own my mailbox. This is the architecture that finally made it boring.

The shape

inbound:   internet -> MX: postfix relay on url :25 -> overlay -> Stalwart (hermes)
clients:   IMAP/JMAP/submission -> url TCP-passthrough (SNI) -> Stalwart
outbound:  Stalwart -> relay-provider ladder -> world
                       (url's own IP: last resort only)

Stalwart is the mail server, the modern all-in-one: IMAP, JMAP, SMTP, CalDAV/CardDAV, spam filtering, web admin, one container. It runs on hermes with every port bound to the overlay IP only (post 5’s pattern at its best). The machine holding years of mail is unreachable from the internet. Not firewalled. Absent.

url plays two small parts. A tiny Postfix relay is the public MX: receives on :25, forwards across the overlay, and holds mail briefly if the tunnel is down (a feature: the internet retries on its schedule, a relay you control retries on yours). And post 7’s Traefik TCP passthrough carries IMAPS and submission by SNI. Stalwart terminates its own TLS; the edge never holds mail plaintext or mail keys. Officer URL checks IDs at the door, he doesn’t read your mail.

Outbound: assume your IP is worthless

All sending goes through relay providers that have what we can’t build: reputation. The SMTP2GO/Brevo/Mailjet/Resend class all run free tiers (hundreds per day or thousands per month; personal volume rounds to zero against any of them). Pick one and you’ve shipped. But one provider is one point of failure, so Stalwart’s outbound walks a ladder keyed on retry count:

attempt 1-2 -> provider A      attempt 5-6 -> provider C
attempt 3-4 -> provider B      attempt 7-8 -> provider D
attempt 9+  -> url's own IP (delivers most places; beats queueing forever)

A provider outage, a quota blip, an account flag: the message walks down a rung and delivers a few minutes late, no human involved. To be clear about what this is: redundancy, not quota games. Month to month, the fallback rungs barely see a message. It’s RAID for SMTP.

The detail that makes the ladder work: DKIM alignment on the root domain. Every provider gets domain-verified for example.dev itself, each with its own DKIM key in DNS, so every rung signs d=example.dev. DMARC passes by DKIM alignment no matter who carried the message, and p=reject stays safe. Avoid providers that only verify a subdomain; your identity shouldn’t depend on routing luck.

Check each rung before trusting it: send a real message through it and read Gmail’s “show original.” You want spf=pass, dkim=pass (d=example.dev), dmarc=pass, per rung.

The pitfalls, in the order they hurt

The EHLO mail loop. The strangest bounce of the build: “mail forwarding loop” on a message that touched exactly two of my machines. Cause: the relay on url greets the world as mail.example.dev (it’s the MX; its EHLO must match). I had also, per every tutorial ever written, set the internal Stalwart’s hostname to mail.example.dev. Postfix connected downstream, heard its own identity echoed back, reasonably concluded it was talking to itself, and bounced everything.

Two SMTP hops cannot share a name. The internal server keeps its internal hostname. The world-facing identity belongs to the relay alone. Tutorials miss this because tutorials only have one hop.

The stale config cache. Stalwart persists settings to its database immediately, and the running process can keep serving the OLD routing config from memory. I ran two “successful” test sends through a rule I had already replaced, and only caught it because the provider dashboards disagreed with my expectations. House rule: restart after every routing change, then verify with a real message, checked at the receiving end. “It saved” and “it’s live” are different claims.

Silent relay rot. The relay is the inbound MX and the outbound last resort, singular enough that when mail dies in BOTH directions at once, you check it first. Mine broke once from a poisoned anonymous volume: stale generated config baked into a volume that outlived its generator (post 3’s no-anonymous-volumes rule, enforced by irony). Triage from anywhere on the overlay: echo > /dev/tcp/10.99.0.30/25. Port dead, suspect found.

Apps are senders too. The vault’s 2FA codes and the SSO’s password resets matter more than your correspondence. Every app submits to Stalwart over the overlay (port 587, a dedicated account) and rides the same ladder. In this lab the apps all send as the hypnotoad, because when an email demands your attention, ALL GLORY TO THE HYPNOTOAD. One outbound pipeline, one set of logs, one DMARC story, instead of six apps with six half-configured SMTP settings.

The DNS appendix

Mail is half DNS. The full inventory, so nothing is mystery meat: MX points at the relay. SPF authorizes your senders (mind the 10-lookup limit, which provider include chains eat quickly). DKIM gets one key per ladder rung, all on the root domain. DMARC starts at p=quarantine with reports and graduates to p=reject once a week of reports shows only you. MTA-STS and TLS-RPT if you’re feeling thorough. Stalwart can publish its records itself through a DNS API token, scoped per post 2. Same religion, last verse.

Worth it?

The longest chapter of the build, and the only one where the adversary was other people’s policy rather than my own mistakes. In exchange: my mail on my domain, my server’s logs when something looks odd, calendars and contacts on the same box, and every app’s outbound mail in one pipeline I can actually read.

That’s the lab, documented end to end. Post 3’s repo holds all of it; everything else is a service file away. The deep dives (Plex, the *arrs) live alongside as posts 15 and 16, and whatever breaks next gets a post of its own.

Series complete. Thanks for reading along.