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 and Stalwart 0.16.20 throughout. Follow it top to bottom, or hand it to an AI agent and let it work the phases (section 10). Two things this page names but does not pin: Stalwart’s own installer script (section 3) and whatever state its database-backed configuration wizard produces, which no file here reproduces.
Verified here: every command, record, port, and path below was run or read off my own server in September 2026. The DNS values and DKIM keys are what the box actually serves. Cost and time figures are what I paid and how long it took on this box, not quotes: treat them as one datapoint.
Every command and record below uses placeholders (
yourdomain.com,203.0.113.10). Swap in your own values.What you need first: a domain you own, a VPS you can rebuild without asking anyone (Linode here), reverse DNS you can set on that VPS, an account at a DNS host you can add records to (Cloudflare here), and an SSH key pair. Nothing below assumes you have run a mail server before.
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, and a server you run keeps your own mailbox out of a provider’s training data.
Cost: a domain (~$10/yr) and a small Linux VPS (roughly $5 to $12 a month). Time: an afternoon for the build, a weekend once DNS propagation and the first deliverability checks are in.
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.
- Create the instance and note its IP. You’ll use it constantly.
- Set the PTR record in the Linode panel. Your DNS host can’t; reverse
records live with the IP owner. Point
203.0.113.10atmail.yourdomain.com, then verify withdig -x 203.0.113.10. - 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
Why not just run it at home?
Your house is the wrong place for a mail server, and it is not a configuration problem:
- Inbound port 25 is blocked. Most residential ISPs block port 25 entirely, so outside mail servers cannot reach you at all. No inbound mail, no matter how perfect your setup.
- You do not control reverse DNS. Receivers check that your IP’s PTR points at your mail hostname. Residential IPs have no PTR or a generic ISP one you cannot change, and mail from an IP without a matching PTR is filtered hard.
- Your IP is not yours. Residential addresses are dynamic, and the ranges are heavily abused, so your neighbors’ botnets drag your IP’s reputation down. When the IP changes, your reputation starts over from zero.
- Your ISP forbids it. Consumer terms of service generally prohibit running servers, and a mail server is the most visible way to violate them.
That is exactly what the VPS is for: a static IP you control, reverse DNS you set in the provider panel, outbound port 25 open, and address space that is not pre-judged by every receiver on the internet.
2. DNS: the four records that make mail real
Running a mail server is mostly DNS. Get these four records right and most of the hard work is done. My DNS is at Cloudflare, with records added in the dashboard. Any TXT-capable host works the same way. 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:
| Type | Name | Value |
|---|---|---|
| A | 203.0.113.10 | |
| MX | @ | mail.yourdomain.com (prio 10) |
| TXT | @ | v=spf1 mx ip4:203.0.113.10 -all |
| TXT | _dmarc | v=DMARC1; p=none; rua=mailto:[email protected] |
| TXT | (see step 5) | DKIM public keys, added after install |
The critical rule: 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
Note (bootstrap login): the first boot creates a one-time admin password and
prints it to the journal only. Read it before you open the wizard:
journalctl -u stalwart -n 200 | grep -A8 'bootstrap mode'.
The installer runs a first-run setup wizard on plain HTTP at
http://127.0.0.1:8080/admin. Tunnel it locally; don’t expose it to the
internet: ssh -L 8080:127.0.0.1:8080 root@YOUR_SERVER_IP, then open
http://127.0.0.1:8080/admin. The wizard walks you through:
- Signing in with the bootstrap admin password
- Adding your domain (
yourdomain.com) - Creating the first mailbox. The username is the full address
(
[email protected], never a bareyou) - Showing you the DKIM records to publish (next step)
Security note (Architecture & IAM): the admin interface is the most sensitive part of the setup. 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.
A note on 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. Every setting this post changes is applied in the WebUI;
stalwart-cli is the same surface from a shell, and its subcommands are in
Stalwart’s own docs. The one part that is file-based is the TLS material in
section 4. If you follow this runbook on a different version, apply the
settings by name in the admin UI rather than pasting a config file, because
there is no file to paste.
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
install -d -o stalwart -g stalwart -m 750 /etc/stalwart/certs
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 when a message bounces. 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. Send your first mail and build reputation
Three rules outrank every setting in this post. They come from the deliverability section at the end, and they are what receivers actually score:
- One origin per domain. Never let a device or a service send as your domain from somewhere else. Your own SPF failures show up in your DMARC reports as your problem.
- Steady beats bursty. A sudden spike from a normally quiet IP reads as a hijacked or bought account. Send at a human pace, and keep it that way for weeks before you judge anything.
- A rejection is information, not an obstacle. Read the code, fix the root cause, and wait. Retrying harder against a reputation block makes it worse.
Run these checks from the server:
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 three providers: Gmail, iCloud, and 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 built over time. Your IP is a stranger to the big providers. For the first weeks:
- Start with DMARC
p=noneand actually read the aggregate reports that land at[email protected](rua). 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, thenp=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.
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 happened to me, and it will happen to 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. Once you own a domain, expect typosquatters to register names close to yours. Check From addresses carefully, and consider watching for new registrations like that.
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 here (a box that also serves a website shows 443)
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:
nc -z mail.yourdomain.com 993 && nc -z mail.yourdomain.com 465, run from outside your network rather than from the server itself. - 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.
- DNS extras to add 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 a written recovery plan. Know how to reach the provider’s rescue console before you need it.
9. Avoid My Mistakes
- 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 when the cert lapses.
- Judging the server from your home connection. The mail server lives on the VPS. Test it from the server itself or over authenticated 465/587, never by opening port-25 connections from your couch: many residential ISPs block outbound 25, so a failure from home says nothing about the server.
- 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.
Threat model
A mail server is the one box in this series that has to be reachable from the whole internet and that holds the contents of your life. Both of those are permanent, so the guardrails and their limits both deserve stating.
What the guardrails stop. Submission and IMAP are authenticated and
TLS-wrapped. The management WebUI binds to localhost and you reach it over an
SSH tunnel, never a port forward. The DKIM private keys live in a root-only
directory, and SPF, DKIM, and DMARC together mean a stranger cannot send as
your domain and have it land. fail2ban handles SSH brute force and Stalwart
bans repeated auth failures on its own. The heartbeat catches the outage you
would otherwise learn about from a bounce.
What they cannot stop. Five things. The admin interface is the whole server: whoever gets into it can read every mailbox and mint credentials, which is why its password and your SSH key are the two secrets that matter most. A leaked DKIM private key is a forgery kit for your domain. Mail sits unencrypted on the VPS by default, so your provider’s disk is inside your trust boundary whether you like it or not. The auth-failure ban is a denial of service wearing a security badge: a phone with a stale password can trip it and lock out the household, which is the real reason the allowlist exists. And the thing you install is third-party code running as root, fetched over the network, on the box that holds your mail.
Minimum hardening. A dedicated non-root user owns the data store, and only
the cert and DKIM directories need group access. SSH keys only, no password
auth, fail2ban from day one. Back the data store up and restore it once to a
throwaway box before you trust the backup, because single-copy mail is a known
gap rather than a policy. Keep the postmaster inbox monitored, since bounces
and DMARC reports are how reputation problems reach you before they turn into
blocks. If you automate against this box, use scoped credentials and put a
human approval gate in front of anything that touches DNS or outbound mail.
The honest sentence. Running your own mail means you own its reputation and its security. Nobody upstream will notice a problem on your behalf.
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. Review what it changes before those changes go live.
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.
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:
- 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.
- 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.
- 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. - 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. Save it as
/usr/local/sbin/mail-heartbeat.py, root-owned at mode 755, because the cron line below calls it by that path:
#!/usr/bin/env python3
# Mail heartbeat: proves auth, submission, and 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.
None of this is a one-time job. The heartbeat and the log checks above are how you find out mail broke before the people you emailed do.
12. How the big providers judge your mail
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 stage has a reason you can find, if you know where to look.
The signals are 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), complaint rate (every “report spam” click is feedback, and a high rate is the fastest way to a block), bounces (repeated mail to invalid addresses signals bad list hygiene, so prune hard bounces immediately), authentication (SPF, DKIM, and DMARC alignment are mandatory, and DMARC failures are logged against your reputation even when the mail is accepted), and the blocklist ecosystem (providers share intelligence, so one Spamhaus or Proofpoint listing gets you rejected by many receivers at once).
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 shows your domain’s spam rate and authentication data for free; the old Domain and IP Reputation dashboards were retired, so the spam rate and auth panels are the signal. 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
HM08orCS01(“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.1family 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.
- Send to people who know you, from an IP with history, authenticated properly, and it will land. Providers filter for the patterns abuse makes, and normal personal mail does not match those patterns.
