Running Two Postfix Instances on One Host: Inbound Filtering vs. Outbound Sending
A single Postfix instance shares one queue, one set of process limits, and one policy stack across inbound and outbound mail — so a spam wave on port 25 can starve your OTP queue and a compromised app can turn your MX into a relay. Here's the real postmulti setup that gives you full queue, process, and IP isolation on the same box in about half an hour.
EvilMail TeamJuly 28, 202611 min read
Why one Postfix process is a liability
One Postfix instance means one active queue, one set of smtpd process limits, and one master.cf — which also means one failure mode. A backscatter storm or a big inbound spam run fills the active queue with junk, the queue manager grinds through it in arrival order, and your password-reset and OTP mail sits behind ten thousand messages nobody wants. On a transactional platform that's the exact mail your users notice missing — the one-time code that shows up four minutes late, after they've already hit "resend" twice.
The deeper problem is that inbound and outbound mail have opposite tuning goals. Inbound wants aggressive filtering: postscreen on port 25, DNSBLs, greylisting, connection-rate caps, a spam-scanning milter on every message. Outbound wants the opposite instincts — protect reputation, hold per-destination concurrency down so Gmail doesn't tarpit you, sign with DKIM, back off politely on a 4xx. Cram both into one config and every knob is a compromise. Raise smtpd_client_connection_rate_limit to survive a legitimate inbound burst and you've also loosened the throttle on anything that hijacks your submission port.
The usual escape hatches cost more than they should. A second physical server duplicates certificates, DNS, monitoring, and patching for what is really a queue-isolation problem. Containers drag the queue onto overlayfs, where Postfix's
Postfix Multi-Instance: Split Inbound Filtering from Outbound Sending — EvilMail Blog
fsync()
-heavy queue writes become a latency tax and, on a bad volume driver, a durability risk. And "do it all in one instance" is the status quo you're trying to leave.
Postfix has shipped the right answer since version 2.6 in 2009: multiple instances of the same binary, each with its own queue, config, process group, and policy, managed by postmulti. No third-party tooling, still current in Postfix 3.10. You get two completely separate mail personalities on one host in about thirty minutes.
How postmulti actually models instances
Get the mental model straight before you touch a config file. postmulti manages N instances that share exactly one Postfix installation — the same daemons under daemon_directory (/usr/lib/postfix), the same commands under command_directory (/usr/sbin), the same shlib_directory and meta_directory. What each instance owns for itself is state: its own config_directory (/etc/postfix, /etc/postfix-out), its own queue_directory (/var/spool/postfix, /var/spool/postfix-out), its own data_directory, its own master.cf, and its own syslog_name so the logs don't blur together.
Multi-instance is off by default. You enable it on the existing default instance first, and from then on postfix start becomes a group operation that walks every instance — while each one still answers to its own postfix -c /path/to/config handle for targeted reloads and queue work.
Standing up the outbound instance
Enable multi-instance on the primary, then create the second instance. The default instance keeps the entire existing MX role; the new one starts blank and gets locked down to outbound-only.
bash
# Enable multi-instance on the default instance
postconf -e "multi_instance_wrapper = \${command_directory}/postmulti -p --"
postconf -e "multi_instance_enable = yes"
# Initialize multi-instance support and register the default instance
postmulti -e init
# Create the outbound instance in the 'mta' group, then enable it
postmulti -I postfix-out -G mta -e create
postmulti -i postfix-out -e enable
The -G mta group tag lets you act on both instances together later (postmulti -g mta -p status). A freshly created instance gets its own main.cf plus a copy of the stock master.cf — which still carries an smtpd listener on port 25. You strip that listener out and pin the instance to loopback so it can never accept public mail. Now write the outbound overrides:
inet_interfaces = loopback-only guarantees this instance never binds a public interface, so nothing you do to master.cf can accidentally expose an open relay. Empty mydestination and relay_domains are the two settings that prevent mail loops — more on that below.
Routing mail between the two instances
This is the linchpin. Two flows cross the boundary, and they cross in opposite directions.
Your own transactional and webmail mail should never touch the inbound filter. Point your app and webmail at a submission listener on the outbound instance directly — submission on port 587 in postfix-out/master.cf. It gets DKIM-signed and delivered without ever being scored by rspamd's inbound rules, which is what you want for a code you generated yourself.
Mail that arrives on port 25 and is bound for the internet — forwarders, aliases, list traffic — has to cross from inbound to outbound after it clears filtering. The inbound instance hands it to a loopback listener on the outbound instance. In /etc/postfix/main.cf:
# Inbound: after filtering, relay outbound-bound mail to the out instance
default_transport = smtp:[127.0.0.1]:10026
And in postfix-out/master.cf, the receiving side plus a per-transport concurrency override:
# postfix-out/master.cf
127.0.0.1:10026 inet n - y - - smtpd
-o smtpd_authorized_xclient_hosts=127.0.0.0/8
-o smtpd_client_restrictions=permit_mynetworks,reject
-o cleanup_service_name=cleanup
submission inet n - y - - smtpd
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
# smtp transport with tight per-destination pacing
smtp-out unix - - y - - smtp
-o smtp_destination_concurrency_limit=20
-o smtp_destination_rate_delay=1s
The loop-prevention rule, stated plainly: the outbound instance must not list any of your domains in mydestination or relay_domains. If it did, mail handed to it for internet delivery would be treated as local, bounce, and ping-pong back. The inbound instance, in turn, must correctly resolve which mail is local (delivered to mailboxes) versus foreign (relayed to :10026) — a mismatch there produces 554 5.4.6 too many hops or an endless bounce loop between the two queues.
Splitting the policy stack: milters, DKIM, rate limits
The whole reason to do this is that the two instances can now run genuinely different policy.
Inbound (/etc/postfix) runs the defensive stack: postscreen fronting port 25, DNSBLs, a spam-scanning milter, and connection throttles.
One thing that bites people: the same milter socket referenced in both master.cf files is two independent milter client connections, not one shared handle. If your DKIM signer or rspamd runs a fixed worker pool, size it for the combined connection count from both instances, or you'll see milter timeouts under load that look like a network problem and aren't.
Binding outbound to its own IP and PTR
Deliverability lives or dies here, and multi-instance makes it easy to forget. Set smtp_bind_address (and smtp_bind_address6 if you send over v6) on the outbound instance to the IP whose reverse DNS you control. Leave the inbound instance on the MX IP. The receiving world only ever sees the outbound IP as your sending identity, so that's the only PTR, SPF, and DKIM alignment that matters for reputation.
The DNS that has to line up with smtp_bind_address = 203.0.113.10:
PTR: 203.0.113.10 → out.evilmail.pro, and that name must resolve forward back to the same IP.
SPF: v=spf1 ip4:203.0.113.10 -all on the sending domain.
DKIM: selector published for the OpenDKIM key the outbound signer uses.
DMARC: v=DMARC1; p=quarantine; adkim=s; aspf=s for strict alignment, once you've confirmed SPF and DKIM both pass.
Do not advertise the outbound IP anywhere on the inbound side — no shared HELO name, no SPF entry that conflates the two roles.
Operating two instances day to day
Everything targets an instance by its config directory. The muscle memory:
bash
postmulti -l # list instances and groups
postmulti -g mta -p status # status of the whole group
postqueue -c /etc/postfix-out -p # outbound queue only
postfix -c /etc/postfix-out reload # reload outbound without touching inbound
postsuper -c /etc/postfix-out -d ALL # flush ONLY the outbound queue
That last one is the payoff for all this work. When a reputation incident hits — a compromised app account blasts a few thousand messages before you catch it — you delete the outbound queue in one command and your inbound flow never even notices. Log separation follows syslog_name, so grep postfix-out /var/log/mail.log gives you a clean outbound-only view for rate-limit and deferral analysis.
On Debian packaging, the shipped postfix.service starts the entire group; the per-instance systemd unit is postfix@postfix\x2dout.service if you need to touch just one under systemd. Overriding the group service to manage instances independently is possible but fiddly — most shops leave it alone and drive individual instances with postfix -c for reloads.
Checklist before you cut over
1.postmulti -l shows both instances; postfix check and postfix check -c /etc/postfix-out both return clean.
2.Both queue directories exist with the ownership postmulti created (a hand-made /var/spool/postfix-out with wrong permissions fails silently at first mail).
3.Outbound smtpd is off on port 25 — confirm with ss -ltnp | grep :25 showing only the inbound instance.
4.Loop-test both directions: submit from the app to :587 and confirm it delivers via postfix-out only; send an external message through a forwarder and confirm it crosses :10026 exactly once, with no bounce ping-pong.
5.A test send carries exactly one DKIM signature, applied by the outbound signer.
6.PTR, SPF, and DMARC verified with a real send to a Gmail account plus a mail-tester.com probe — aim for 10/10.
7.Log separation confirmed: inbound and outbound traffic land under distinct syslog_name values.
8.Monitoring watches both active queues independently, with a separate alert threshold on each.
Thirty minutes of postmulti, and a spam wave on port 25 can no longer touch the queue carrying your users' one-time codes — which, for a mail platform, is the whole game.