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 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.
# 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 enableThe -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:
postmulti -i postfix-out -x postconf -e \
"myhostname = out.evilmail.pro" \
"syslog_name = postfix-out" \
"smtp_bind_address = 203.0.113.10" \
"inet_interfaces = loopback-only" \
"mydestination =" \
"relay_domains =" \
"smtp_destination_concurrency_limit = 20" \
"smtp_destination_rate_delay = 1s" \
"initial_destination_concurrency = 5"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]:10026And 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=1sThe 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.
# /etc/postfix/main.cf
postscreen_dnsbl_sites = zen.spamhaus.org*2 bl.spamcop.net*1
postscreen_dnsbl_threshold = 2
smtpd_milters = inet:127.0.0.1:11332
smtpd_client_connection_rate_limit = 30
smtpd_client_connection_count_limit = 10
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination,
reject_rbl_client zen.spamhaus.orgOutbound (/etc/postfix-out) runs a sign-only milter and reputation pacing — no filtering, no DNSBLs:
# /etc/postfix-out/main.cf
smtpd_milters = inet:127.0.0.1:8891
non_smtpd_milters = inet:127.0.0.1:8891
milter_default_action = accept
smtp_tls_security_level = mayOne 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 -allon the sending domain. - DKIM: selector published for the OpenDKIM key the outbound signer uses.
- DMARC:
v=DMARC1; p=quarantine; adkim=s; aspf=sfor 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:
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 queueThat 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 -lshows both instances;postfix checkandpostfix check -c /etc/postfix-outboth return clean. - 2.Both queue directories exist with the ownership postmulti created (a hand-made
/var/spool/postfix-outwith wrong permissions fails silently at first mail). - 3.Outbound
smtpdis off on port 25 — confirm withss -ltnp | grep :25showing only the inbound instance. - 4.Loop-test both directions: submit from the app to
:587and confirm it delivers viapostfix-outonly; send an external message through a forwarder and confirm it crosses:10026exactly once, with no bounce ping-pong. - 5.A test send carries exactly one DKIM signature, applied by the outbound signer.
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.


