OpenSMTPD from Scratch: A Readable Multi-Domain Mail Server
Postfix multi-domain configs rot into 500 lines across main.cf, master.cf, and a pile of hash maps. OpenSMTPD fixes that with a real ruleset grammar. Here is the full from-scratch build: two domains, Dovecot delivery, rspamd filtering, and the DNS that actually decides whether your mail lands.
EvilMail TeamJuly 10, 202611 min read
Why OpenSMTPD, and why the config stays readable
A Postfix multi-domain setup sprawls. Directives scatter across main.cf, service definitions live in master.cf, and a directory of hash: maps has to be postmap-ed every time you touch it — virtual_mailbox_domains, virtual_mailbox_maps, virtual_alias_maps, smtpd_sender_login_maps, and on it goes. None of it reads top to bottom. A year later you cannot tell, just by looking, whether port 587 requires authentication, because the answer is spread across four files and two lookup tables.
OpenSMTPD throws that model out. Its config is a small grammar with four verbs that matter:
OpenSMTPD Multi-Domain Mail Server Setup (2026 Guide) | evilmail — EvilMail Blog
pki
,
table
,
listen
, and
match ... action
. Every message that enters the daemon is checked against the
match
rules in order, top to bottom,
first match wins
, and each rule points at a named
action
block that says what to do with the message — deliver locally over LMTP, or relay. That is the entire mental model. There is no separate "map" concept bolted on; a
table
is just a named data source you reference with angle brackets.
Scope for this build: an authoritative mail server for two domains (evilmail.pro and evilmail.cloud), local virtual mailboxes delivered over LMTP to Dovecot, submission on 587 and 465 with SASL, and rspamd doing inbound filtering plus DKIM signing. Everything targets the modern OpenSMTPD grammar introduced in the 6.4 rewrite, which ships native on OpenBSD 7.6 and as opensmtpd-portable on Debian 12 and AlmaLinux 9. Portable deltas are called out inline.
The moving parts and how mail flows through them
Draw a hard boundary before touching any config: OpenSMTPD never writes a mailbox to disk. It receives, queues, filters, and either relays outbound or hands the message to Dovecot over LMTP. Dovecot owns the mail store, serves IMAP, and is the SASL authentication backend. That last point is the one people miss — OpenSMTPD delegates AUTH to Dovecot, so there is exactly one password database on the box, not two that drift apart.
The user and domain source here is flat files. Move to SQL or LDAP later and you swap one table line — table domains sqlite:/etc/mail/sqlite.conf — while nothing else in the ruleset changes. That decoupling is why the config stays legible.
Tables: virtual domains and users without per-domain files
Three tables carry all the tenant data. No per-domain include files, no directory of snippets to keep in sync.
/etc/mail/domains is a bare list, one hosted domain per line:
text
evilmail.pro
evilmail.cloud
/etc/mail/virtuals maps a recipient address to a local delivery target or expands an alias. The left column is the envelope recipient; the right is where it goes:
Here vmail is the system user that owns /var/vmail; Dovecot's LMTP service resolves the real maildir from the recipient address. An entry whose right side is another address is a plain alias, expanded before delivery. Do not add a catch-all (@evilmail.pro vmail) unless you have a specific reason — a catch-all silently accepts mail for every nonexistent address, which turns backscatter and bounce noise into delivered spam and gives spammers an infinite supply of valid recipients at your domain.
/etc/mail/credentials only matters for portable installs that are not delegating AUTH to Dovecot. The format is login bcrypt-hash, and you generate the hash with the built-in tool — never store a plaintext password:
bash
smtpctl encrypt 'the-user-password'
# $2b$10$Q9.... ← paste this into /etc/mail/credentials
Aliases (the for local world) and virtuals (the for domain world) are different namespaces on purpose: aliases route root, postmaster, and cron mail to a real human; virtuals handle your hosted-domain addresses. Keeping them separate is what lets you host evilmail.pro mail without your OpenBSD system accounts leaking into it.
smtpd.conf: the whole ruleset on one screen
This is the centerpiece — the entire server config, annotated. Read it top to bottom; that is also the order the daemon evaluates it.
conf
# --- TLS material ---
pki mail.evilmail.pro cert "/etc/ssl/mail.evilmail.pro.crt"
pki mail.evilmail.pro key "/etc/ssl/private/mail.evilmail.pro.key"
# --- data sources ---
table domains file:/etc/mail/domains
table virtuals file:/etc/mail/virtuals
table creds file:/etc/mail/credentials
# --- inbound filtering ---
filter check_rspamd proc-exec "filter-rspamd"
# --- listeners ---
listen on egress tls pki mail.evilmail.pro \
hostname mail.evilmail.pro filter check_rspamd
listen on egress port 587 tls-require pki mail.evilmail.pro \
auth <creds> hostname mail.evilmail.pro
listen on egress smtps pki mail.evilmail.pro \
auth <creds> hostname mail.evilmail.pro
# --- named actions ---
action "local_mail" lmtp "/var/dovecot/lmtp" rcpt-to virtual <virtuals>
action "outbound" relay helo mail.evilmail.pro
# --- match rules: first match wins ---
match from any for domain <domains> action "local_mail"
match from local for local action "local_mail"
match from any auth for any action "outbound"
The ordering is not cosmetic — it is the security boundary. Rules are evaluated top to bottom and the first match decides the fate of the message. The relay rule is last and carries auth, so only an authenticated submission can trigger a relay. Flip that ordering — put a match from any for any action "outbound" above the auth rule — and you have built an open relay that the entire internet will find within hours. MXToolbox's open-relay check tests for exactly this; an open relay gets your IP blacklisted the same day.
listen on egress binds to whatever interface holds the default route, which is cleaner than hardcoding an IP. Port 25 gets opportunistic tls and runs the rspamd filter. Submission on 587 uses tls-require, meaning the client must complete STARTTLS before it may issue AUTH. Port 465 (smtps) is implicit TLS from the first byte.
Before you reload anything, syntax-check:
bash
smtpd -n # MUST print "configuration OK"
rcctl reload smtpd # OpenBSD
# systemctl reload opensmtpd # Debian/Alma (portable)
smtpctl show queue # empty on a healthy idle box
smtpctl show stats # envelopes, scheduler, uptime
On portable installs the LMTP action changes from the unix socket to action "local_mail" lmtp "127.0.0.1:24" ..., and certs come from /etc/letsencrypt/live/mail.evilmail.pro/ instead of /etc/ssl.
TLS, submission, and SASL that actually authenticates
The pki block just names a cert and key; obtaining the cert is a separate job. On OpenBSD, run acme-client -v mail.evilmail.pro against an /etc/acme-client.conf entry, wired to a weekly cron for renewal. On Linux, certbot certonly --standalone -d mail.evilmail.pro with a deploy hook that reloads smtpd.
Three rules keep submission honest:
Port 25 must never accept plaintext AUTH. The port-25 listener above has no auth keyword at all, which is correct — inbound MX traffic is other servers delivering mail to you, not your users logging in. There is nothing to authenticate.
587 uses `tls-require`, so credentials cross an encrypted channel or not at all. Plain tls (opportunistic) is fine for MX-to-MX on port 25 but wrong for submission.
Use auth <creds> for a hard requirement. auth-optional exists and has exactly one legitimate use — a transition window — and is a footgun everywhere else.
In the full build AUTH is delegated to Dovecot, so the auth reference points at Dovecot's auth socket rather than the flat creds file — a Dovecot-side service auth detail. The payoff is the single-password-store property: a user's IMAP password and their submission password are the same secret, by construction.
DKIM, SPF, DMARC, MTA-STS — deliverability lives in DNS
Here is the part no amount of MTA tuning changes: whether your mail lands is decided in your DNS zone, not in smtpd.conf. A perfectly configured OpenSMTPD on a cold IP with no DKIM goes straight to spam at Gmail. Get these records right the first time.
DKIM signing is rspamd's job, not smtpd's. Generate a keypair and print the DNS record in one shot:
bash
rspamadm dkim_keygen -s mail -d evilmail.pro \
-k /var/lib/rspamd/dkim/evilmail.pro.mail.key
That writes the private key and prints the exact mail._domainkey.evilmail.pro TXT value — a 2048-bit key, which most DNS providers split into quoted strings. Point rspamd's dkim_signing.conf at the key directory with selector = "mail" and a path template, and every authenticated outbound message gets signed. The signing config lives entirely in rspamd; smtpd.conf only knows "run the filter."
The rest is DNS you publish once. Use SPF -all (hard fail) only after you are certain mx a:mail.evilmail.pro covers every source that sends as your domain. Start DMARC at p=none with a working rua= so you actually read the aggregate reports before tightening — jumping straight to p=reject blind is how people quarantine their own legitimate mail. Set adkim=s; aspf=s for strict alignment. MTA-STS plus TLS-RPT tell sending servers to require TLS to your MX and report failures; the policy file lives at https://mta-sts.evilmail.pro/.well-known/mta-sts.txt with mode: enforce.
The single most-forgotten record is PTR. If reverse DNS for your IP does not resolve back to the HELO name (mail.evilmail.pro), the major providers greylist or reject on sight, no matter how clean your SPF and DKIM are. Set it at your hosting provider, not in your own zone.
Verify, then send: the pre-flight checklist
Run through this before you send a single real message, and again after any config change:
smtpd -n prints configuration OK — never reload on a failed parse.
openssl s_client -starttls smtp -connect mail.evilmail.pro:587 negotiates TLS and advertises 250-AUTHonly after STARTTLS.
openssl s_client -connect mail.evilmail.pro:465 presents the cert with implicit TLS.
MXToolbox open-relay test on port 25 returns not an open relay — non-negotiable.
Send a test to a mail-tester.com address and score 10/10; every point off names a specific fix.
In a Gmail "Show original", confirm SPF=PASS, DKIM=PASS, and DMARC=PASS with alignment on your From domain.
smtpctl show queue is empty and /var/log/maillog shows clean delivery: Ok lines, not deferrals.
One hard opinion to close on: in 2026, do not self-host your outbound MX unless you have handled PTR, warmed the IP, and set up postmaster feedback loops (Google Postmaster Tools, Microsoft SNDS) before you send volume. A fresh IP with no reputation gets throttled hard. Keep your inbound side fully authoritative on OpenSMTPD — that part you control completely — and route outbound through a reputable smarthost until your own IP has earned its reputation. Confirm your /24 is off the major RBLs first; getting delisted after the fact costs days you do not want to spend.