What this is: the exact runbook I followed to get off Gmail and onto my own mail server, end to end: rent a VPS, install Stalwart, wire up DNS, get mail delivered. I use Linode for the VPS and Cloudflare for DNS, but substitute your own providers’ equivalents where they differ. Ubuntu 24.04 throughout. Follow it top to bottom, or hand it to an AI agent as the spec.

Every command and record below uses placeholders (yourdomain.com, 203.0.113.10). Swap in your own values.

0. Why do this at all

Your mail provider reads your mail: to target ads today, to train models tomorrow. And the address you use everywhere is only rented. The provider owns it and can take it away. A domain you own is an identity you can carry between providers. A server you run is mail that stops being anyone’s training data.

Cost: a domain (~$10/yr) and a small Linux VPS (roughly $5 to $12 a month). Time: an afternoon, most of it waiting for DNS.

1. Rent the VPS

I run on Linode (Ubuntu 24.04, 2GB plan, Fremont). I picked it for three reasons: predictable pricing, a one-click Ubuntu image, and reverse DNS that’s two fields in the panel. The 1GB plan is enough for mail alone, and any provider that hands you a static IPv4 with panel-set rDNS will do the same job.

  1. Create the instance and note its IP. You’ll use it constantly.
  2. Set the PTR record in the Linode panel. Your DNS host can’t; reverse records live with the IP owner. Point 203.0.113.10 at mail.yourdomain.com, then verify with dig -x 203.0.113.10.
  3. First login, then lock down the basics:
apt update && apt upgrade -y
apt install -y ufw fail2ban
ufw allow 22,25,80,465,587,993/tcp
ufw --force enable

Security note (Network & SecOps): firewall discipline is least privilege in practice. Inbound 25/465/587/993 for mail, 22 for you, and 80 only for certificate renewal. Every open port should have a reason you can name, and nothing else should be listening.

IPv6: leave it disabled unless your provider gives you a v6 address AND you set a v6 PTR. Gmail rejects mail from v6 senders without a v6 PTR record:

printf 'net.ipv6.conf.all.disable_ipv6 = 1\nnet.ipv6.conf.default.disable_ipv6 = 1\n' \
  > /etc/sysctl.d/99-disable-ipv6.conf && sysctl -p /etc/sysctl.d/99-disable-ipv6.conf

2. DNS: the four records that make mail real

Here’s the thing nobody tells you: most of “running a mail server” is DNS. My DNS lives at Cloudflare, added in the dashboard. It works identically at any TXT-capable host. The records, in plain English:

  • MX: where does mail for this domain get delivered? Point it at mail.yourdomain.com
  • SPF: which IPs are allowed to send as this domain? Your VPS, and nothing else
  • DKIM: a signature on every message so receivers can verify it’s really you
  • DMARC: what to do when SPF/DKIM fail, and where to send you reports

Create them before the server does anything:

TypeNameValue
Amail203.0.113.10
MX@mail.yourdomain.com (prio 10)
TXT@v=spf1 mx ip4:203.0.113.10 -all
TXT_dmarcv=DMARC1; p=none; rua=mailto:[email protected]
TXT(see step 5)DKIM public keys, added after install

The rule that bites everyone: mail records stay DNS-only (grey cloud). Proxying only understands HTTP. An orange-clouded MX silently kills SMTP. Proxy your web subdomains all you like; never proxy a mail record.

Security note (Network Security): when you’re ready, two cheap DNS hardening wins: a CAA record that restricts certificate issuance to Let’s Encrypt, and DNSSEC at your DNS host. Neither affects mail delivery, and both narrow what an attacker can do with your zone.

3. Install Stalwart

Stalwart is an all-in-one Rust mail server (SMTP, IMAP, JMAP, contacts, the lot) with sane defaults and a modern admin UI. I originally ran Maddy, which is also excellent: one config file, dead simple. I switched to Stalwart a day later for its admin UI and JMAP support. Both are great, so pick one and stay. Migrating later is a few hours of mailbox copying you don’t need.

curl --proto '=https' --tlsv1.2 -sSf https://get.stalw.art/install.sh -o install.sh
less install.sh          # read it before running it. You always read installers.
sudo bash install.sh
systemctl status stalwart

The installer runs a first-run setup wizard over HTTPS on port 8080. Tunnel it locally; don’t expose it to the internet: ssh -L 8080:127.0.0.1:8080 root@YOUR_SERVER_IP. The wizard walks you through:

  1. Creating the admin login
  2. Adding your domain (yourdomain.com)
  3. Creating the first mailbox. The username is the full address ([email protected], never a bare you)
  4. Showing you the DKIM records to publish (next step)

Security note (Architecture & IAM): the admin plane is the crown jewels. The management WebUI binds to localhost only and you reach it over an SSH tunnel. Never port-forward it. Keep admin credentials in a password manager, separate from mailbox passwords.

One thing to know: in current Stalwart, configuration lives in the database, not in text files. config.json only points at the data store. You administer it through the WebUI or the stalwart-cli tool, not by editing config files.

4. TLS certificate (and the trap that waits here)

Get a Let’s Encrypt certificate for mail.yourdomain.com, then make Stalwart use it. My install reads certs from files in /etc/stalwart/certs/:

apt install -y certbot
certbot certonly --standalone -d mail.yourdomain.com   # needs port 80 free
install -o stalwart -g stalwart -m 640 \
  /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem /etc/stalwart/certs/fullchain.pem
install -o stalwart -g stalwart -m 640 \
  /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem  /etc/stalwart/certs/privkey.pem
systemctl restart stalwart

(If your install manages certs through the WebUI instead, upload them there and skip the file copies.)

The trap: the certificate renews automatically, but nothing tells the mail server about it. Without a deploy hook that copies the fresh cert into place and restarts the service, your server quietly keeps serving the old cert until it expires. You find out at 3am. Write the hook on day one:

# /etc/letsencrypt/renewal-hooks/deploy/stalwart-copy.sh  (chmod +x)
#!/bin/bash
install -o stalwart -g stalwart -m 640 /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem /etc/stalwart/certs/fullchain.pem
install -o stalwart -g stalwart -m 640 /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem  /etc/stalwart/certs/privkey.pem
systemctl restart stalwart

Test it immediately: run certbot renew --force-renewal once and confirm the server still answers with a valid cert.

5. Publish the DKIM keys

The wizard generated two signing keys: an ed25519 key and an RSA key. Stalwart dual-signs on purpose, and you publish both records. I date-stamp my selectors so rotating keys means adding a new one and letting the old retire naturally. Use the date you set yours up:

TXT v1-ed25519-20260904._domainkey.yourdomain.com.  v=DKIM1; k=ed25519; h=sha256; p=…
TXT v1-rsa-20260904._domainkey.yourdomain.com.      v=DKIM1; k=rsa;     h=sha256; p=…

Verify from the server once they propagate (paste in your real selector):

dig +short v1-rsa-20260904._domainkey.yourdomain.com TXT
dig +short v1-ed25519-20260904._domainkey.yourdomain.com TXT

The byte lesson: DNS values are bytes, not text. A record that looks perfect in a dashboard can be silently wrapped in invisible typographic quotes from a copy-paste. Every receiver then fails DKIM for that key. This cost me a full day of iCloud bounces that Gmail never showed me. When things fail for no visible reason, look at the record the way machines do: dig +short … | xxd | head.

Why keep the RSA key: some receivers (Apple’s iCloud notably) bounce mail whose only valid signature is ed25519. Dual-signing means a broken RSA record is survivable with Gmail but can still get you blocked at iCloud. Keep both records byte-perfect.

Security note (Asset Security & Cryptography): those DKIM private keys on the server are signing keys. Protect them at rest in service-owned, root-only directories, and treat them like any credential: if you ever suspect compromise, rotate by adding a new dated selector and retiring the old one. That’s exactly why date-stamped selectors exist.

6. First real send + the reputation ramp

Verify everything talks:

dig +short MX yourdomain.com         # want: mail.yourdomain.com
dig +short TXT yourdomain.com        # want: spf record present
dig +short TXT _dmarc.yourdomain.com # want: dmarc present
openssl s_client -connect mail.yourdomain.com:465 -servername mail.yourdomain.com </dev/null 2>/dev/null | grep subject

Send a test from your new mailbox to at least two providers: Gmail and iCloud, plus a second non-Google inbox. One test recipient is never enough. Gmail accepting means nothing about iCloud, and the failure modes are completely different.

Check what actually happened from the receiving side. In Gmail, open “Show original” and the headers should read spf=pass dkim=pass dmarc=pass.

Reputation is earned, not configured. Your IP is a stranger to the big providers. For the first weeks:

  • Start with DMARC p=none and actually read the aggregate reports that land at [email protected] (rua). They’re your flight recorder. Every authentication failure shows up there.
  • Keep sending honest, low-volume mail. Don’t blast test messages at the same providers all day. That is the behavior they penalize.
  • Only after weeks of clean reports, ramp DMARC to p=quarantine, then p=reject.

Security note (Security Assessment): DMARC aggregate reports are a free, continuous audit feed of everyone trying to send as your domain. Treat them as a detective control and read them on a schedule, not just when something breaks. Failures you don’t look at are failures you don’t have.

7. Configure clients

Every client gets the same five settings (I use Apple Mail on iPhone and macOS; any standards client works):

IMAP  mail.yourdomain.com   port 993   SSL/TLS   username: [email protected]
SMTP  mail.yourdomain.com   port 465   SSL/TLS   username: [email protected]
        (or port 587 with STARTTLS. Pick one and be consistent)
  • Username is always the full address. Bare names are rejected by design.
  • iOS trap: the phone stores the incoming (IMAP) password and the outgoing (SMTP) password as two separate fields. Updating the account password does not touch the outgoing one. When sending fails after a password change, that’s the first place to look. This bit me, and it will bite you.
  • Paste passwords, don’t type them. A leading space produces an error that looks exactly like a wrong password.

Security note (IAM & Awareness): keep the mailbox password in a password manager and never reuse it anywhere. And once you own a domain, you become phishing bait. Typosquatters register lookalikes of it, so check From addresses carefully and consider monitoring for lookalike registrations of your domain.

8. Day-2 operations

systemctl status stalwart            # is it up?
journalctl -u stalwart -n 50         # everything logs here
ufw status                           # 22,25,80,465,587,993 and nothing else
fail2ban-client status sshd          # ssh brute-force bans (mail has its own)

Things to set up while you still remember:

  • Allowlist your home IP in Stalwart’s admin. Otherwise a phone with a stale password trips the auth-failure ban and locks out your whole household. Residential IPs rotate, so expect to re-add after ISP changes.
  • Monitoring that probes the right port. Many residential ISPs block outbound port 25, so a “is the server down” check that tests :25 from home will lie to you. Probe IMAPS 993 / SMTPS 465 instead.
  • Backups. Mail is the one dataset you can’t recreate. Snapshot the data store properly (stop the service, or use a quiescent copy; never tar a live database). Honest footnote: my own server currently runs single-copy. That’s a known gap, not a recommendation. Do as I say, not as I do.
  • Optional-but-nice DNS extras when you’re ready: MTA-STS, TLS-RPT, CAA, and SRV records for auto-discovery (documented on stalw.art/docs/install/dns).

Security note (Security Operations): round out day two with a patch cadence (subscribe to the project’s release notes), integrity monitoring on system files, and knowledge of your provider’s rescue path. Console access and a written recovery plan are your incident-response starter kit. You hope to never need them; you’ll be glad they exist when you do.

9. Mistakes worth 30 seconds each

  • Invisible characters in DNS values (step 5). Cost me a full day of iCloud bounces that Gmail never showed.
  • Forgotten cert deploy hook (step 4). Silent expiry at 3am.
  • Testing from a residential connection. Your ISP blocks outbound :25, so “it doesn’t work from home” is not “it doesn’t work”.
  • One test recipient. Passing Gmail does not mean passing iCloud.
  • Client passwords. iOS keeps SMTP separate from IMAP. Full addresses only, and no leading spaces.

10. Hand it to an agent

This runbook is deliberately structured so an AI agent can execute it. Each phase has exact commands and a verification step. Give an agent shell access to the VPS, point it at this page, and have it work through the phases, reporting after each one. Review before it touches DNS or sends real mail. The boring parts are exactly where you want a second pair of eyes.

Security note (IAM & Change Management): give any automation the same least-privilege treatment you’d give a human: scoped credentials that can only do the job, never root. And keep yourself in the approval path for anything touching DNS or mail. Separation of duties is a control, not bureaucracy.

And if privacy is the point of the whole exercise, keep the agent’s brain on hardware you control too. A model served from your own network means the setup session never leaves your LAN. You stay the engineer; the agent is just fast hands.

11. Prove it from your client, then keep watching

Setting up clients (section 7) is not the same as proving they work. Do the round trip before you call this done:

  1. Send from your phone or laptop. Compose from your mail app to an address on another provider (Gmail or iCloud). It should send without errors and appear in your Sent folder once it syncs.
  2. Receive on your server. Reply from that external account back to [email protected]. It should land in your Inbox within seconds. Check your Junk folder for the first couple of weeks. If legitimate mail lands there, that’s a deliverability signal worth chasing.
  3. Check the headers once. On the received message, view the full headers and confirm spf=pass dkim=pass dmarc=pass. That one check proves the whole authentication chain end to end.
  4. Confirm sync, not just delivery. If you use more than one device, the sent message should show up on all of them. That proves IMAP sync is healthy, not just the inbound path.

Then keep watching, because servers drift:

  • The reachability probe from section 8 (IMAPS 993 and SMTPS 465, never port 25 from home) tells you the ports answer, but not that mail flows. Add a functional heartbeat that actually authenticates on both paths:
#!/usr/bin/env python3
# Mail heartbeat: proves auth + submission + IMAP all respond. Exit 0 = healthy.
import imaplib, smtplib, ssl, os

ctx = ssl.create_default_context()
user = "[email protected]"
pwd = os.environ.get("MAIL_PASSWORD", "CHANGE_ME")

s = smtplib.SMTP_SSL("mail.yourdomain.com", 465, context=ctx)
s.login(user, pwd)
s.quit()

m = imaplib.IMAP4_SSL("mail.yourdomain.com", 993, ssl_context=ctx)
m.login(user, pwd)
m.select("INBOX")
m.logout()
print("mail heartbeat OK")
*/10 * * * * /usr/local/sbin/mail-heartbeat.py || logger -t mail-heartbeat "mail check FAILED"
  • Watch the server log for the two lines that matter: journalctl -u stalwart | grep "Message delivered" confirms outbound success, and anything that says “Message rejected by remote server” means a permanent failure with a DSN waiting in the postmaster inbox.
  • Read the postmaster inbox on a schedule. Bounces and DMARC reports land there, and both are early warning signs.

A mail server is a promise you keep renewing. The ten minutes of setup above is how you notice the promise broke before your contacts do.

12. How the big providers judge your mail

Reputation is not one switch. Providers score your IP and your domain on an ongoing basis, and they act in degrees: deliver normally, then land in spam, then start deferring with temporary errors (“try again later”), and only then hard-reject with a permanent code. Each rung has a reason attached, if you know where to look. The main signals:

  • Volume and velocity. A sudden spike from a normally quiet IP reads as a hijacked or bought account. Steady, honest volume is the safest pattern.
  • Complaints. Every “report spam” click from a recipient is feedback. High complaint rates are the fastest way to a block.
  • Bounces. Repeated mail to invalid addresses signals bad list hygiene. If you ever send to a list, prune hard bounces immediately.
  • Authentication. SPF, DKIM, and DMARC alignment are table stakes now. The big providers all check them, and DMARC failures are logged against your reputation even when the mail is accepted.
  • The blocklist ecosystem. Providers share intelligence. A listing on Spamhaus or Proofpoint gets you rejected by many receivers at once, which is why a block that looks provider-specific can actually be a list problem.

What that looks like at the big guys, since they are your real test:

  • Gmail / Google Workspace: scores both your IP and your domain, and reacts hard to user complaints. Temporary “421/451 please try again later” errors usually mean rate limiting while it decides about you. Google’s Postmaster Tools dashboard shows your domain’s reputation and spam rate for free; set it up if you send any volume at all.
  • Apple (iCloud/Mail): the strictest of the four and the least transparent. Rejections carry bracketed codes like HM08 or CS01 (“local policy”) that map to reputation categories, but Apple offers no feedback loop and no allowlist. Recovery is slow. This is also the receiver that bounced everything when my RSA DKIM record was corrupted, while Gmail stayed quiet. Apple is your canary.
  • Microsoft 365 / Outlook: heavily complaint-driven, with programs (SNDS, JMRP) that let senders see complaint data. Rejections for bad IP reputation usually land in the 5.7.1 family with a note like “messages from your IP were not sent.” Clean up the cause, don’t hammer retries.
  • Yahoo / AOL: merged infrastructure, heavily reliant on Spamhaus and user complaints. New IPs often see deferrals before acceptance while the domain builds history.

Practical habits that keep you off every one of those lists:

  • Send from one stable IP and domain, and keep the envelope sender aligned with the visible From address. Never let a device or service send as your domain from somewhere else; your own SPF failures show up in your DMARC reports and cost reputation.
  • Don’t test-blast. A dozen messages to the same providers in an hour is the behavior they penalize. Space out tests, vary recipients.
  • Watch your own numbers: DMARC reports for authentication failures, postmaster inbox for bounces, and a monthly Spamhaus lookup of your IP.
  • If a receiver rejects you, read the code, fix the root cause, and wait. Retrying harder against a reputation block makes it worse, and the block usually clears faster if you go quiet for a day or two.
  • Treat recipient addresses like people, not rows in a table. Mail you send to people who know you, from an IP with history, authenticated properly, will land. The providers are not out to get you; they are filtering for the patterns that abuse looks like, and normal personal mail does not match those patterns.

Recap: domain, VPS with PTR, four DNS records (grey, always grey), Stalwart, certs with a working renewal hook, DKIM keys published and byte-verified, DMARC p=none with watched reports, and honest mail. A weekend, a few dollars a month, and every message you send or receive belongs to you.