~$ dobryops.com
~ / writing / self-hosted-mail-stalwart-brevo

Self-hosting mail, then remembering why nobody likes self-hosting mail

Cloudflare Email Routing forwarded mail at dobryops.com to a Gmail account for about a year. That worked. I wanted to actually run the mailbox myself. So I deployed Stalwart in k3s, relayed outbound through Brevo, learned a lot, and eventually paid Proton because email is less a protocol and more a geopolitical hostage negotiation with MIME attachments.

I already had a homelab running everything else on my domain - blog, Matrix, finance, the usual emotional-support YAML. Email was the obvious next thing, and the obvious next thing I had been avoiding, because the folklore around self-hosted mail is very simple:

You can run an MTA. You cannot make Gmail respect your little server.

The folklore is mostly correct.

Still, I wanted to learn. I wanted IMAP at my own domain, real mailboxes, DNS records I understood, DKIM keys I generated myself, and the satisfying feeling of taking a famously hostile piece of internet infrastructure and placing it gently inside Kubernetes, which is a sentence that should probably invalidate my passport.

So I cheated in the traditional way: inbound mail was mine, outbound reputation was somebody else’s problem.

The first shape of it

The original architecture looked like this:

INBOUND
  any sender -> MX dobryops.com (10 mail.dobryops.com)
             -> Hetzner VPS:25
             -> Pangolin / Traefik TCP entrypoint
             -> kernel-WireGuard tunnel to my home server
             -> kube-proxy externalIP -> Stalwart pod
             -> IMAP at mail.dobryops.com:993

OUTBOUND
  Stalwart pod -> smtp-relay.brevo.com:587
              -> Brevo signs with DKIM
              -> recipient

The receiver lived in my cluster. The sender went through Brevo.

This split is the first big lesson: inbound email is basically plumbing. Outbound email is politics.

// rule of thumb Receiving mail is a TCP-forwarding problem. Sending mail is a reputation problem. If you solve both with the same enthusiasm, one of them will laugh at you.

Hetzner receiving port 25 was fine. Hetzner sending port 25 was not fine. Even if I got it unblocked, I would still have an IP address with all the reputation of a guy in a trench coat selling USB sticks outside a train station. Brevo existed to make that part somebody else’s problem.

For a while, this worked.

Stalwart was honestly pretty nice

I picked Stalwart because it is one Rust binary that does SMTP, submission, IMAP, JMAP, DKIM, ARC, MTA-STS, a spam filter, and a webadmin UI. The alternative was Postfix + Dovecot + rspamd + opendkim + a small, ritualized loss of innocence.

The k3s deployment was normal homelab machinery: Helm chart, SOPS secrets, PVC, service, Pangolin route. The service exposed the mail ports on the WireGuard tunnel IP:

service:
  main:
    externalIPs: ["100.89.128.16"]
    ports:
      smtp:        { port: 25 }
      submission:  { port: 587 }
      submissions: { port: 465 }
      imaps:       { port: 993 }
      http:        { port: 8083 }

I got real mailboxes. I got IMAP on my phone. I got DKIM and DMARC and all the little green checkmarks that make you believe the machine likes you.

And, because this is a homelab blog, I also got several hilarious footguns.

Things that bit me before the final boss appeared

A YAML block scalar ate my private key

The first deploy rendered the Stalwart Secret with the DKIM key as a stringData block:

stringData:
  dkim.key: |
    ${dkim_private_key}

The placeholder expanded to a multi-line PEM. YAML block scalars require every line to be indented. My substitution indented the first line and then politely let the rest of the private key escape into column 1 like a raccoon exiting an air vent.

k3s rejected the manifest with:

invalid Yaml document separator: --END PRIVATE KEY-----

Fix: store the PEM as single-line base64 in SOPS and use Kubernetes data:. Base64 is ugly, but YAML is a loaded weapon, so we all make compromises.

The mailadmin URL opened qBittorrent

https://mailadmin.dobryops.com rendered the qBittorrent UI.

This was not a metaphor.

The reason: multiple services shared the same externalIPs tunnel address. That means ports are a flat namespace. qBittorrent had already claimed 100.89.128.16:8080. Stalwart’s admin UI also wanted 8080. kube-proxy did what kube-proxy does best: silently made my life more educational.

Fix: move Stalwart admin to 8083 and write down, in large crayon letters:

Shared external IP means globally unique ports, you decorative mammal.

Traefik was not listening on the mail ports

I had opened the firewall. I had registered Pangolin resources. I had verified Stalwart worked inside the cluster.

I had not told Traefik on the VPS to actually listen on 25/465/587/993.

Dynamic config cannot attach to entrypoints that do not exist. This is obvious in hindsight, which is where all obvious things live.

Fix: add static Traefik entrypoints.

Stalwart 0.13 did not like declarative principals

I tried declaring users directly in config.toml:

[[principal]]
type = "individual"
name = "dobry"
secret = "${dobry_password}"
emails = ["dobry@dobryops.com"]
roles = ["user"]

Stalwart loaded them, warned about them, displayed enough hints that they existed, and then did not authenticate them. Very cool. Very “the door is painted on the wall”.

Fix: use the fallback admin, then create domains/principals/routes through the admin API from an idempotent Kubernetes Job.

The API returned 200 OK for errors

Some Stalwart admin endpoints return 200 OK even when the body says:

{"error":"notFound","item":"dobryops.com"}

So the bootstrap job had to inspect response bodies, not status codes. Domain first, principals second, relay routes after that. Annoying, but manageable.

At this point, the whole setup was actually respectable. It was declarative-ish, rebuildable, and worked from a fresh checkout. I had mail in, mail out, and a working reminder that email is older than many labor laws.

The Brevo moment

The collapse did not start with an outage.

It started because I tried to add a second user - basically a service account for one of my projects - and noticed the backend could not send email.

Fine. Normal. Probably my code. Probably a missing environment variable. Probably me, because statistically it is usually me.

I dug through the app. Nothing obvious. I checked the mail side. Nothing obvious. Then I went to investigate my Brevo account state.

Password did not work.

Tried a reset.

Reset email did not arrive.

This is not ideal behavior from a company currently holding the “please make my email leave the building” part of the architecture.

Then I sent a test email from dobry@dobryops.com to a Gmail account I own and noticed something that made my soul leave my body for a short walk:

From: dobry@11297523.brevosend.com

Not dobry@dobryops.com.

dobry@11297523.brevosend.com.

A sender address that looks less like a human wrote it and more like a phishing kit sneezed into a CRM.

// incident severity The mail was technically delivered. The vibes were not.

Maybe there was a reasonable explanation. Maybe I had misconfigured something. Maybe Brevo had silently changed account state, domain verification, sender identity, plan behavior, or some other dashboard checkbox hidden behind a modal written by a committee of growth hackers.

But by that point the important conclusion had already arrived:

I do not want to be my own mail provider badly enough to debug this.

So I said the traditional operations prayer - “fuck it” - and decommissioned the whole self-hosted mail stack.

The new shape: Proton gets the cursed chalice

I removed the cluster mail namespace, closed the VPS mail ports, deleted the Pangolin mail resources, cleaned DNS, and moved dobryops.com to Proton Mail.

The new architecture is beautiful:

INBOUND
  internet -> Proton MX -> mailbox

OUTBOUND
  mailbox -> Proton -> recipient

HOMELAB
  does not run SMTP
  does not run IMAP
  sleeps better

I still own the domain. I still have dobry@dobryops.com. I still have custom addresses for projects. I just no longer maintain a tiny postal service in my office and ask Google, Microsoft, Brevo, DNSBLs, SPF, DKIM, DMARC, PTR records, and the ghost of RFC 5321 to please behave.

Why Proton?

Because my hatred for Google and Microsoft as service providers is load-bearing, and Proton is the only proprietary email service I do not despise yet. The “yet” is important. I am an optimist, but only in the way a raccoon is an urban planner.

Was self-hosting mail a mistake?

No.

It was a great learning project.

I learned how inbound SMTP behaves behind a VPS and WireGuard tunnel. I learned where Traefik static entrypoints end and dynamic config begins. I learned how Stalwart models principals, how DKIM alignment actually matters, why relay providers mutate messages, and how much of email deliverability has nothing to do with the software you run.

But was it worth continuing to operate?

Also no.

Mail hosting is heavily politicized infrastructure. Not in the cable-news sense, but in the “large institutions decide whether your packets are socially acceptable” sense. The hard part is not running SMTP. The hard part is being trusted by the handful of giant mail providers who act as the de facto border police for human communication.

You can be technically correct and still land in spam.

You can have SPF, DKIM, DMARC, TLS, reverse DNS, clean logs, and a pure heart, and still get treated like a guy selling counterfeit sunglasses from a folding table.

That is the part people mean when they say self-hosting mail is hard.

Things I would still recommend

If your goal is to learn:

  • Self-host mail once.
  • Wire up SPF, DKIM, DMARC.
  • Read the headers Gmail gives you.
  • Break DKIM by modifying the body and watch the hash fail.
  • Try receiving mail over port 25 and sending through a relay.
  • Feel the ancient machinery move under your feet.

It is absolutely worth doing as an educational exercise.

If your goal is reliable email for a domain you actually care about:

  • Pay a reputable provider.
  • Keep your DNS clean.
  • Spend your homelab energy somewhere less cursed, like distributed storage or pretending Kubernetes is simple.

Things I would not do again

  • I would not run a personal production mailbox in my cluster unless the point was explicitly learning.
  • I would not rely on a marketing-email relay as the long-term outbound identity for human mail.
  • I would not pretend “delivered” means “looks trustworthy to the recipient.”
  • I would not expose mail ports through the edge just because I can.

Where this leaves the homelab

Today, the homelab does not run SMTP or IMAP.

dobryops.com mail is hosted by Proton. DNS has Proton MX, SPF, DKIM, and the usual verification records. The old Stalwart namespace is gone. The old Brevo records are gone. The VPS no longer listens on mail ports. The dashboard mail link points to Proton instead of my now-retired Stalwart admin UI.

This is less cool than self-hosting mail.

It is also better.

And that, unfortunately, is how infrastructure maturity usually feels: fewer heroics, fewer ports, fewer dashboards, and one more monthly subscription that buys back a piece of your weekend.

filed under #nix #homelab #mail #k3s #stalwart #proton