DEV Community

Remdore
Remdore

Posted on AI-assisted

I deleted the mail server from my VPS and got 1.2 GB of RAM back

So I run ExtraTime, it's a football trivia site with daily puzzles, the Grid game, multiplayer rooms and leaderboards and so on. Backend is Django on one VPS with 8 GB RAM, frontend is Next.js on Vercel. Pretty normal side project setup that kind of grew bigger than I planned.

Until this October the same VPS was also running my own mail server, and I want to write down how I moved off it to smtpfa.st, because honestly it went way better than I expected, and the part where stuff broke was actually the best part (I'll explain).

Nobody asked me to write this post. I'm writing it because I think how it went is worth sharing with other people who self-host their mail without really needing to.

How it was before

When I started I thought email is something I should just own myself, how hard can it be? So I put docker-mailserver in a container next to the app, then a SnappyMail container on top so I can read admin@ from the browser, and then a whole separate git repo only for the mail config. Django sent through it on port 587.

It worked, mostly. But when I actually looked inside the container this October it was running Postfix, Dovecot, rspamd for spam, ClamAV for viruses (just the signature database was 309 MB on disk), and fail2ban because ports 25, 587 and 993 were open to the internet and the bots found them quickly. All of this so my site can send password reset emails and verification links to people, which is the most basic thing an app does.

docker stats showed the mailserver using 1.14 GiB. The same box runs Postgres, Redis, gunicorn, daphne for the websockets, Prometheus and some exporters, so 1.14 GiB for a thing that does nothing most of the day felt wasteful. Early October I was looking where to free some memory and the first idea was to turn ClamAV off, and then I thought, if I'm touching the mail server anyway why not just remove the whole thing and use a proper service.

The RAM was honestly not even the main problem for me. The main problem was I had zero visibility, if a reset email didn't arrive I had no idea if it went to spam or bounced or the address was fake or what. There was no dashboard, no events, nothing I could connect Django to. DMARC was on p=none and the reports were going to a mailbox that I almost never opened.

Plus I wanted to start a newsletter, and sending a newsletter from your own Postfix on a VPS IP address is a pretty good way to get that IP on a blocklist.

Account emails first

This part was boring, in a good way.

smtpfa.st has an SMTP relay so for Django it's only settings, and the password is an API key that can only send:

EMAIL_HOST = "smtp.smtpfa.st"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "smtpfast"
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")  # send-only key
Enter fullscreen mode Exit fullscreen mode

I added their DNS records in Cloudflare, the dashboard showed everything verified, I sent a password reset to myself and it was there in Gmail and in the smtpfa.st logs. That was it.

One thing I didn't know: smtpfa.st treats mail that comes over SMTP as marketing by default, unless you say it isn't. Makes sense because they add the unsubscribe headers and respect opt-outs, but a password reset obviously has to arrive even if the person unsubscribed from the newsletter. There is a header for this, so I made a small email backend that puts it on every email Django sends:

from django.core.mail.backends.smtp import EmailBackend

CATEGORY_HEADER = "X-SMTPfast-Category"
UNTRACKED_HEADERS = {
    "X-SMTPfast-Track-Clicks": "false",
    "X-SMTPfast-Track-Opens": "false",
}


class TransactionalEmailBackend(EmailBackend):
    def send_messages(self, email_messages):
        for message in email_messages or ():
            message.extra_headers.setdefault(CATEGORY_HEADER, "transactional")
            for header, value in UNTRACKED_HEADERS.items():
                message.extra_headers.setdefault(header, value)
        return super().send_messages(email_messages)
Enter fullscreen mode Exit fullscreen mode

They read the headers and remove them before sending. The two tracking headers I added a few days later, there's a story behind those further down.

The newsletter

This is really why I did the whole thing. ExtraTime has around 2,200 registered players from the last few years and I had literally no way to tell them when we add something new, except a banner on the site that only people already on the site will see.

What I ended up with: Django is the source of truth for consent. You turn product emails on or off in your profile and a nightly job syncs that to the contacts in smtpfa.st, and if they ever disagree Django wins. The email templates are React Email in the frontend repo with the same Tailwind colours as the site, a build step turns them to HTML and uploads them as hosted templates, so the emails actually look like ExtraTime and not like some generic template.

Broadcasts I create with the REST API. When we launched country variations for the Grid I didn't want people in Brazil getting the email at 4 in the morning, so the audience got split by country and time zone, which ended up being 18 scheduled broadcasts for one campaign (a bit much, in hindsight). Now I do one batch per day at 10:00 Sofia time and it's much simpler.

Unsubscribes, spam complaints and bounces come back to Django as webhooks, signed with HMAC-SHA256, so verifying is short:

def signature_is_valid(body, header):
    secret = settings.SMTPFAST_WEBHOOK_SECRET
    if not secret or not header:
        return False
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    received = header.strip().removeprefix("sha256=")
    return hmac.compare_digest(expected.encode(), received.encode("utf-8", "replace"))
Enter fullscreen mode Exit fullscreen mode

Every event is saved by its id first so if they retry it nothing happens twice. And for people without an account there's a hosted sign-up form with double opt-in, the /newsletter page on our site posts straight to it and smtpfa.st sends the confirmation mail.

I built most of this with Claude Code, and smtpfa.st has an MCP server and a skill file for agents, which helped more than I thought it would, because the agent was reading the real API response shapes and not guessing them.

Bounces (my fault, not theirs)

First campaign I sent to everyone who was opted in, and a lot of them had not logged in for years. The bounce rate went to 3.9%, and sending gets paused at 5%, and soft bounces count too, not only hard ones. Not a fun number to watch climb.

But this was entirely on me. smtpfa.st showed exactly which addresses bounced and the reason, mostly typos and dead domains from old signups. After that I made a rule that any address that bounces even once never gets mailed again, and we check the MX records too so dead domains get removed before the first send. If you are about to email an old user list for the first time, clean it first.

When stuff broke

This is the part that made me want to write all this.

On October 10, the first day, I hit a bunch of small problems while building everything above, so I wrote them all down and sent them to smtpfa.st support. The same evening I got a reply that started with "Everything you reported is fixed and live now" and a list. Marketing and transactional mail got split, so an unsubscribe doesn't block password resets anymore (that's where the header above comes from). Suppressions now show the reason why an address is blocked. There was a paging bug where contacts in a segment could repeat or disappear because the sort had no tiebreaker, fixed. Tags started coming back on GET /v1/emails/:id, and the docs got the real webhook payloads and bounce types.

Next morning two more things were live, send_over_minutes for spreading a broadcast over a few hours, and custom link domains so the tracking and unsubscribe links use your own hostname. I added one CNAME for a subdomain of extratime.world and the certificate just appeared on its own.

On the 12th I noticed the links inside password reset emails were going through the click tracker. Those links have single-use tokens in them and I really didn't like that they pass through a redirect, so I asked if I can turn tracking off for single emails. First answer was that none of the options exist yet but they will add them. Second answer came the same afternoon and all four were live: SMTP headers, API fields, an account switch for transactional mail, and an attribute for single links. That's the X-SMTPfast-Track-* thing in my backend.

After that I started reporting things as GitHub issues, and the timestamps tell the story better than I can:

Issue Opened Fix PR Closed
#841 Contacts stat boxes weren't clickable filters Oct 14, 07:56 UTC 23 min later ~3 h
#842 No way to archive sent broadcasts Oct 14, 07:59 UTC ~1.5 h later ~3 h
#853 MCP OAuth rejected Claude Code's localhost callback Oct 16, 08:50 UTC 10 min later 20 min

853 is my favourite. Claude Code uses a random localhost port for the OAuth callback and their server only accepted the exact redirect that was registered, so connecting failed. I opened the issue and it was fixed 20 minutes later.

I've worked with the big email providers before and you know how that goes, ticket, auto-reply, and after a week someone says it's on the roadmap. This was nothing like that.

Removing the old server

When everything was going through smtpfa.st, removing the old mail server was quick. I backed up the two mailboxes (less than 200 KB each, which tells you how much I actually used them), stopped and deleted the mailserver and SnappyMail containers, closed 25, 587 and 993 in the firewall, removed the VPS IP from the SPF record, and pointed MX to Cloudflare Email Routing which forwards everything to my Gmail.

Used RAM went from 5,537 MB to 4,322 MB. And it's one repo less, three ports less, and no more ClamAV updating its signatures in the middle of the night for an inbox nobody reads.

So would I recommend it

Yes. It's a young service and in the first weeks you could tell: when I started only the CLI was public, and the dashboard had some rough spots, two of which are in the table above. That moved fast too. There are now official SDKs for Node, Python, Go and PHP, plus a Laravel package and a Terraform provider. But the sending, the webhooks and the docs have been solid for me, and I have never had a provider fix things this fast.

If you are still running Postfix next to your app only for password resets, you probably don't need to.

And if you like football come play the daily Grid on ExtraTime. The newsletter will tell you when there's something new, and this time I can actually see if it arrived.

Top comments (1)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani • • Edited

deleting the mail server to get 1.2gb back says a lot about what it was costing.