Exim Routers and Transports: Build Virtual Hosting You Can Actually Debug
Most "add a mail domain" guides hand you a monolithic Exim config and tell you to trust it. This one teaches Exim's execution model instead — an ordered list of routers, one transport that wins — so hosting 500 domains becomes two lookup tables and 15 lines of config you never touch again.
EvilMail TeamJuly 11, 202610 min read
A message comes in for [email protected]. The mailbox exists — /var/mail/vhosts/evilmail.pro/alice/ is right there on disk, owned by vmail, new/ cur/ tmp/ all present. And the sender gets back a bounce: Unrouteable address.
Nothing was rejected. No file was missing. No permission was denied. What actually happened is that Exim walked its ordered list of routers, offered the address to each one in turn, and *none of them accepted it*. That distinction — declined versus rejected — is the whole game. Exim didn't say "no." It said "I have nowhere to send this," which almost always means a lookup returned empty and a router that should have gated on your domain quietly declined.
Once you can read the order of routers in exim -bt output, virtual hosting stops being magic. It becomes a routing decision driven by two lookup tables, and adding domain number 501 is one INSERT, not a config edit. We'll build it from parts you can read end to end.
The mental model: routers decide, transports deliver
Every locally-destined address in Exim flows through the same pipeline. The address enters an ordered list of routers
, evaluated strictly top to bottom. Each router looks at the address and returns one of four verdicts:
accept — this address is mine. Stop the chain, assign a transport, done.
decline — not for me. Fall through to the next router.
defer — can't decide right now (lookup timeout, locked file). Try again later.
fail — this address is bad. Bounce it.
Only accept stops the chain. Decline is not an error — it's the normal way an address falls past a router that doesn't apply to it. This is the single most misunderstood thing about Exim: a router "not matching" is expected behavior, not a failure, and ninety percent of routing bugs are a router declining when you expected it to accept.
A router is a decision-maker. It can rewrite the address, run lookups, apply conditions, and — if it accepts — name a transport. A router never touches the message body. A transport is the mechanic: appendfile writes to a Maildir, smtp opens a connection to a remote MX, lmtp hands the message to Dovecot over a socket, pipe feeds it to a program. Transports move bytes; routers decide which transport gets to.
Delivery splits two ways: remote (goes out over SMTP to someone else's server) and local (lands in a mailbox on this box). Virtual hosting lives entirely on the local side. And the tool that shows you all of this without delivering anything is exim -bt — address test mode. We'll lean on it throughout.
Two tables are the whole design
Before a single line of router config, decide the data model. Virtual hosting needs exactly two questions answered:
1.Is this domain ours? — a virtual-domains table.
2.Does this local part exist here, and where does its mail land? — a virtual-mailboxes table.
Start with flat files, because they make the shape obvious:
text
# /etc/exim4/virtual_domains
evilmail.pro: active
evilmail.cloud: active
# /etc/exim4/virtual_mailboxes (address : maildir path)
[email protected]: /var/mail/vhosts/evilmail.pro/alice/
[email protected]: /var/mail/vhosts/evilmail.cloud/bob/
Exim reads these with the lsearch lookup — a linear, key-on-the-left flat-file search. The important part: the router structure does not care that this is a file. Swap lsearch for mysql{SELECT maildir FROM mailboxes WHERE address='${quote_mysql:$local_part@$domain}'} or a redis{...} lookup and every router below stays byte-for-byte identical. Only the expansion string inside the braces changes. That property is what lets a canned stack and a hand-rolled one share the same routing logic.
Two Exim options do the decisive work. On the router, domains = gates whether the router even considers the address. And a lookup — set on the router, then read by the transport — resolves the mailbox path. "Virtual" means nothing more than this: the identity is a row in a table, not a Unix account in /etc/passwd.
Writing the routers
Order is authoritative. Here is the block, top to bottom, with the reasoning baked in:
exim
# 1. Remote mail. Declines anything in our local domains so it falls through.
dnslookup:
driver = dnslookup
domains = ! +local_domains
transport = remote_smtp
no_more
# 2. Virtual aliases — MUST come before delivery, or aliases never expand.
virtual_aliases:
driver = redirect
domains = +local_domains
data = ${lookup{$local_part@$domain}lsearch{/etc/exim4/virtual_aliases}}
# 3. Virtual mailbox delivery.
virtual_domains:
driver = accept
domains = +local_domains
local_part_suffix = +*
local_part_suffix_optional
address_data = ${lookup{$local_part@$domain}lsearch{/etc/exim4/virtual_mailboxes}{$value}fail}
transport = virtual_delivery
# 4. Catch the miss. A clean, deliberate bounce instead of silence.
unknown_user:
driver = redirect
domains = +local_domains
allow_fail
data = :fail: Unknown user $local_part@$domain
Where +local_domains is a domainlist defined once in the main section:
Read the flow. The dnslookup router handles genuinely remote recipients; domains = ! +local_domains skips it for anything we host, so those addresses fall through to the local routers instead of being shipped out over SMTP. virtual_aliases is a redirect driver — its job is to rewrite [email protected] into a real mailbox; if data expands empty, the router declines and we move on. virtual_domains is the accept driver: it gates on our domains, resolves the Maildir path into $address_data, and if the mailbox lookup fails ({$value}fail), the forced expansion failure makes the router decline rather than accept a bogus path. Anything that reaches unknown_user is a genuine miss, and we :fail: it with a message a human can read.
The classic ordering bug lives here: put virtual_aliases *after* virtual_domains and aliases never fire, because delivery accepts first and stops the chain. Routers are a priority list, not a set.
Writing the transports
The router resolved a path into $address_data. The transport consumes it:
maildir_format gives you the new/ cur/ tmp/ structure instead of an mbox blob. create_directory builds the tree on first delivery. Delivery runs as user = vmail / group = vmail — uid/gid 5000 on this stack — never as root, which is why directory_mode = 0700 works and the whole /var/mail/vhosts tree must be owned 5000:5000. Quota goes on the transport, not the router, for a simple reason: quota is a property of *how you store the message*, and the router stores nothing. A router that accepts an over-quota address still accepts; the transport is what defers with "mailbox full" and retries.
When Dovecot owns storage and Sieve, don't use appendfile at all — hand the message to Dovecot over LMTP and let it do delivery, indexing, and Sieve filtering in one place:
Choose LMTP when Dovecot is authoritative for the mailbox: you get Sieve, shared indexes, and consistent locking for free. Choose appendfile when Exim is the only thing writing the store and you want the fewest moving parts. For a per-user forward or a Sieve script run by Exim itself, a pipe transport feeding the message to the filter is the third option — rare in pure virtual hosting, common the moment users want vacation autoresponders.
Debugging routing like an engineer
This is the payoff. exim -bt runs an address through the routers *without delivering* and tells you exactly what happened:
That's unknown_user firing — a *deliberate* failure, exactly what you want. Silence would be the bug. When a router declines and you don't know why, turn on the route debugger:
You'll see each router named, its domains condition evaluated, and the lookup result. This is where the cold-open bounce reveals itself: the domains lookup on virtual_domains returned empty because the domain wasn't in /etc/exim4/virtual_domains yet (or the file hadn't been reloaded), so the router declined, the address fell to unknown_user, and it bounced. The mailbox on disk was a red herring — Exim never got far enough to look at it.
Three states people conflate, made concrete:
declined — router said "not mine," address fell through. Normal.
defer — temporary problem (lookup timeout, quota full, greylist). Message stays queued and retries.
fail — hard rejection. Bounce generated now.
For anything already on the queue:
bash
$ exim -bp # queue listing: age, size, msgid, sender, recipients
$ exim -Mvl 1s9kQ2-abc123 # view the log for one stuck message
exim -bp shows a deferred message's age and recipients; -Mvl shows *why* — the last delivery attempt's error, verbatim. Between -bt, -d+route, and -Mvl you can answer "why did my mail get routed there" for any address without guessing.
Production checklist
The new domain is in the virtual_domains lookup table before you touch DNS — otherwise domains returns empty and every address bounces.
DNS is live: MX 10 mail.evilmail.pro and A mail.evilmail.pro → 203.0.113.10.
SPF: v=spf1 mx -all, DMARC: v=DMARC1; p=quarantine; rua=mailto:[email protected], and a DKIM selector TXT record published.
Delivery runs as vmail (5000:5000), never root; the /var/mail/vhosts tree is owned 5000:5000 with directory_mode 0700.
quota is set on the transport, not the router.
exim -bt on a known address returns router = virtual_domains, transport = virtual_delivery, and an unknown address fails cleanly with "Unknown user."
A real test message lands in the recipient's new/ directory — check the file is there and owned 5000:5000.
Retry rules and greylisting confirmed with a defer test, so temporary failures retry instead of bouncing.
Adding domain 501 is now zero config changes: one INSERT into the domains table, one INSERT per mailbox, and Exim's routers pick it up because the logic is data-driven. That is the whole reason to think in routers and transports instead of copy-pasting a config you can't read — the routing becomes something you query, trace, and trust, at any scale, from fifteen lines you wrote once.