Self-hosted mail server. Sends from your code, receives over IMAP. Postfix
- Dovecot + Rspamd in a single Docker container, ~200 MB of RAM idle.
Don't use this for cold outreach — your IP gets nuked the moment you try. Use it for the transactional stuff that shouldn't be costing you $20/month per project: contact-form replies, password resets, invoices.
.
├── compose.yaml
├── mailserver.env
├── .env.example
├── send.py
└── templates/
├── default.html
└── default.txt
- VPS with a static public IPv4 and root SSH.
- Domain whose DNS you control. Cloudflare works, read the gotcha first.
- Outbound port 25 unblocked. Many cheap VPS tiers block it. Test before doing anything else (see Pre-flight). No software workaround.
- Ability to set rDNS / PTR for your IP, usually a checkbox in the VPS panel. Without this, Gmail rejects almost everything you send.
- ~30 minutes for the first run.
Tested on Ubuntu 24.04. Anything systemd + Docker should work.
From the VPS, before doing anything else:
nc -vz gmail-smtp-in.l.google.com 25 # outbound 25 (it must work)
df -h / # want >5 GB free
free -h # 1 GB is goodIf port 25 is blocked: ask the provider to unblock, switch providers, or use a smarthost like Mailgun's relay. Pick one before continuing.
Replace example.com with your domain and 1.2.3.4 with the VPS IP.
| Type | Name | Value | Notes |
|---|---|---|---|
| A | mail |
1.2.3.4 |
If on Cloudflare: DNS only (grey cloud). The HTTP proxy doesn't speak SMTP. |
| MX | @ |
mail.example.com priority 10 |
DNS only on Cloudflare. |
| TXT | @ |
v=spf1 mx ~all |
|
| TXT | _dmarc |
v=DMARC1; p=none; rua=mailto:postmaster@example.com |
Start p=none, tighten later. |
DKIM comes after step 6 — the server generates the keypair.
In your VPS panel, set the PTR for the IPv4 (and IPv6 if you have one) to
mail.example.com. Single biggest factor for not landing in Gmail spam.
sudo hostnamectl set-hostname mail.example.com
echo "1.2.3.4 mail.example.com mail" | sudo tee -a /etc/hosts
curl -fsSL https://get.docker.com | sudo shIf your distro uses cloud-init's manage_etc_hosts: true, also:
echo "manage_etc_hosts: false" | sudo tee /etc/cloud/cloud.cfg.d/99-disable-hosts-management.cfgsudo apt install -y certbot
sudo certbot certonly --standalone -d mail.example.com \
--agree-tos --email postmaster@example.com --key-type ecdsa--standalone needs port 80 free. If nginx already runs on :80, use
certbot --nginx -d mail.example.com — it won't touch your other sites.
Renewal is automatic via the certbot timer. Add a deploy hook so the mailserver picks up renewed certs without restarting:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/mailserver.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
[[ "${RENEWED_LINEAGE:-}" == /etc/letsencrypt/live/mail.example.com ]] || exit 0
docker ps --format '{{.Names}}' | grep -qx mailserver || exit 0
docker exec mailserver postfix reload || true
docker exec mailserver dovecot reload || true
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mailserver.shgit clone <this-repo> smtp && cd smtp
cp .env.example .env
$EDITOR .env # set MAIL_DOMAIN, MAIL_HOSTNAME, SMTP_* values
docker compose up -dThe container will complain there are no mailboxes and shut itself off after two minutes. Beat the timer:
docker exec -it mailserver setup email add no-reply@example.com
docker exec -it mailserver setup alias add postmaster@example.com no-reply@example.comContainer goes healthy once a mailbox exists.
docker exec mailserver setup config dkim domain example.com keytype rsa keysize 2048 selector mailIt prints a TXT record like:
v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEF...QIDAQAB
Add it at mail._domainkey.example.com. Confirm:
dig +short mail._domainkey.example.com TXT @1.1.1.1python3 send.py you@gmail.com -s "first send"In Gmail, open the message → "Show original". Want all three:
SPF: PASSDKIM: PASSwithd=example.comDMARC: PASS
For a graded report, send to a one-time address from mail-tester.com. 9+/10 is the bar.
python3 send.py recipient@example.com
python3 send.py recipient@example.com -s "Welcome"
python3 send.py recipient@example.com --html my.html --text my.txtsend.py reads .env for SMTP creds and templates/default.{html,txt}
for the body. There's no template engine — edit the files, or use
--html / --text to point at different ones per call. Bring your own
Jinja2 / mako / f-string layer if you need substitution.
From any other language:
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "no-reply@example.com"
msg["To"] = "user@gmail.com"
msg["Subject"] = "Welcome"
msg.set_content("plain text fallback")
msg.add_alternative("<h1>HTML</h1>", subtype="html")
with smtplib.SMTP("mail.example.com", 587) as s:
s.starttls()
s.login("no-reply@example.com", "yourpassword")
s.send_message(msg)STARTTLS on :587, login with the mailbox + password. Same in any
language.
| Setting | Value |
|---|---|
| Server | mail.example.com |
| Port | 993 |
| Security | SSL/TLS |
| Username | full email address |
| Password | what you set with setup email add |
Works with Thunderbird, Apple Mail, K-9, anything.
From Python:
import imaplib
m = imaplib.IMAP4_SSL("mail.example.com", 993)
m.login("no-reply@example.com", "yourpassword")
m.select("INBOX")
typ, data = m.search(None, "UNSEEN")
for num in data[0].split():
typ, msg = m.fetch(num, "(RFC822)")
print(msg[0][1].decode("utf-8", errors="replace"))
m.logout()docker exec -it mailserver setup email add user@example.com
docker exec -it mailserver setup alias add admin@example.com user@example.com
docker exec mailserver setup email list
docker exec mailserver tail -f /var/log/mail/mail.log
docker compose pull && docker compose up -dMail goes to spam in Gmail. Open "Show original" — if SPF/DKIM/DMARC all pass, the IP is just new. No fix beyond consistent low-volume sending over weeks. Don't pay for a "warmup service".
Connection refused on :25. Either Cloudflare is proxying the mail A
record (must be DNS-only), or the provider blocks outbound 25. Test from
the VPS: nc -vz gmail-smtp-in.l.google.com 25.
Mail stuck in queue. docker exec mailserver postqueue -p shows what
and why. Usually a recipient domain blocking your IP — run through
mail-tester to see what they object to.
DKIM fails. Diff what setup config dkim printed against
dig +short mail._domainkey.example.com TXT @1.1.1.1. Cloudflare splits
long TXT into 255-byte chunks — fine, the resolver reassembles.
User doesn't exist listing mailboxes. The Maildir is created on
first delivery, not on setup email add. Send one message and the
directory appears.
Container restart-loops. docker logs mailserver | tail -50. Most
common cause: no mailboxes exist; the container shuts down after 2 min.
Run setup email add.
No bounce-handling API, no delivery webhooks, no per-recipient analytics. If you need that, Postmark or AWS SES.
Single VPS, single container. The box dies, your mail dies. Back up
docker-data/dms/.
ClamAV is off by default to save RAM (ENABLE_CLAMAV=1 in
mailserver.env to enable).