Self-hosted email: Postfix, Dovecot, CoreDNS, DKIM, SPF, and DMARC
Published: 2026-06-10
@antonnovikov.com email is self-hosted on the same VPS as the site. The stack is Postfix (SMTP), Dovecot (IMAP), OpenDKIM (signing), CoreDNS (authoritative DNS), and SnappyMail (webmail). Getting all the moving parts right — especially deliverability — took several iterations.
Why self-host email
Because I wanted to. It's the hardest thing to self-host correctly, which made it interesting. The practical result is that I control the MX records, nothing goes through Google or Microsoft, and I learned what SPF, DKIM, and DMARC actually do.
DNS: CoreDNS as authoritative server
Instead of using the registrar's DNS or Cloudflare, 11-setup-dns.sh deploys CoreDNS inside the k0s cluster as an authoritative nameserver for antonnovikov.com. The zone file is stored in a ConfigMap:
zone@ IN SOA ns1.antonnovikov.com. me.antonnovikov.com. (
2026010101 ; Serial
3600 600 604800 3600
)
@ IN NS ns1.antonnovikov.com.
@ IN A 91.184.248.13
* IN A 91.184.248.13
mail IN A 91.184.248.13
@ IN MX 10 mail.antonnovikov.com.
The wildcard * IN A means every subdomain resolves to the server IP without adding individual records — useful during initial setup.
The SOA serial number matters: downstream resolvers use it to detect zone changes. Increment it (YYYYMMDDNN format) whenever you change the zone, otherwise changes won't propagate.
SPF record
@ IN TXT "v=spf1 ip4:91.184.248.13 a mx -all"
| Mechanism | Meaning |
|---|---|
ip4:91.184.248.13 |
allow mail from this IP |
a |
allow mail from the IP in the A record |
mx |
allow mail from whatever the MX record points to |
-all |
hard fail everything else |
-all is a hardfail: any server not matching the policy is rejected outright (not just marked as spam). The IP, a record, and mx record all point to the same server, so this is tight.
DKIM with OpenDKIM
13-setup-dkim.sh installs OpenDKIM, generates a 2048-bit RSA keypair, and configures Postfix to pipe outgoing mail through the OpenDKIM milter on port 8891.
The public key goes into DNS as a TXT record on mail._domainkey.antonnovikov.com. The selector name mail matches the SELECTOR variable in OpenDKIM's config.
mail._domainkey IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
When you change keys: use a new selector name (e.g., mail2) so old signatures remain verifiable during the transition. Only retire the old selector after you're confident no mail with old signatures is in transit.
The canonicalization mode is relaxed/relaxed — this handles minor whitespace changes that some mail relays introduce, without which signatures would fail on forwarded mail.
Postfix config:
milter_protocol = 6
milter_default_action = accept
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891
DMARC
_dmarc IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@antonnovikov.com;
pct=100; adkim=s; aspf=s; sp=reject; fo=1"
| Tag | Value | Meaning |
|---|---|---|
p |
reject |
reject mail failing DKIM+SPF alignment |
rua |
mailto:... |
send aggregate reports here |
pct |
100 |
apply policy to 100% of messages |
adkim |
s |
strict DKIM alignment |
aspf |
s |
strict SPF alignment |
sp |
reject |
same policy for subdomains |
fo |
1 |
forensic reports on any failure |
Start with p=none while testing, then p=quarantine, then p=reject once you're confident SPF and DKIM are passing.
SnappyMail webmail
SnappyMail is a PHP-based webmail that runs in a pod in the webmail namespace. It connects to Dovecot over IMAP on localhost (hostNetwork: true). The pod has a hostPath volume at /opt/snappymail for persistent config.
yamlspec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
containers:
- name: snappymail
image: djmaze/snappymail:latest
volumeMounts:
- name: snappymail-data
mountPath: /var/lib/snappymail
volumes:
- name: snappymail-data
hostPath:
path: /opt/snappymail
type: DirectoryOrCreate
hostNetwork: true is necessary because Dovecot runs on the host, not inside the cluster. Without it, the webmail container can't reach 127.0.0.1:143.
The webmail is exposed at mail.antonnovikov.com via Traefik IngressRoute with TLS from the shared cert.
Postfix and Dovecot on the host
These run directly on the host (not in Kubernetes) because they need raw access to port 25 and the mail spool, and don't benefit from containerization:
bash# Postfix - send test email
echo "Test" | sendmail -v test@gmail.com
# Check mail queue
postqueue -p
# Force flush queue
postqueue -f
# Dovecot - check auth
doveadm auth test user@antonnovikov.com password
Deliverability checklist
Before sending, verify:
| Check | Command |
|---|---|
| DKIM public key in DNS | dig TXT mail._domainkey.antonnovikov.com |
| DMARC policy in DNS | dig TXT _dmarc.antonnovikov.com |
| SPF record in DNS | dig TXT antonnovikov.com |
| PTR record matches | dig -x 91.184.248.13 → mail.antonnovikov.com |
| Port 25 open | telnet smtp.gmail.com 25 from the VPS |
| TLS working | openssl s_client -connect mail.antonnovikov.com:465 |
Without a matching PTR record, Gmail and others will reject or heavily penalize outgoing mail regardless of SPF/DKIM/DMARC.
Testing deliverability
bash# Send to Gmail and check headers
echo "DKIM test" | sendmail -v test-account@gmail.com
# In Gmail: show original → look for DKIM=pass, SPF=pass, DMARC=pass
# Use mail-tester.com for a score
# Use mxtoolbox.com to check blacklists
Common failure modes:
- "Message rejected" — PTR record missing or wrong
- "SPF check failed" — sending from an IP not in your SPF record
- "DKIM signature invalid" — OpenDKIM not running, or milter not connected to Postfix
- "Goes to spam" — new IP with no reputation. Send a few legitimate emails and wait a week.
Summary
- Postfix (SMTP) + Dovecot (IMAP) + OpenDKIM run on the host; SnappyMail webmail runs in Kubernetes with
hostNetwork: trueto reach localhost services - DMARC starts at
p=none, thenp=quarantine, thenp=reject— don't skip steps or you'll block your own mail - A matching PTR record is mandatory for deliverability; without it Gmail rejects regardless of DKIM/SPF
- CoreDNS runs as the authoritative nameserver inside k0s; increment the SOA serial number on every zone change
- Key rotation: use a new DKIM selector (
mail2) before retiring the old one so in-flight messages remain verifiable