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.13mail.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: true to reach localhost services
  • DMARC starts at p=none, then p=quarantine, then p=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