Load Balancing SMTP and IMAP Behind HAProxy Without Losing the Client IP
Put a load balancer in front of Postfix and Dovecot and every backend suddenly sees the balancer's IP — quietly breaking SPF, DNSBL lookups, postscreen, rate limits, and fail2ban. Here is the one correct fix: HAProxy in mode tcp with the PROXY protocol, wired end to end and firewalled against the spoofing trap.
EvilMail TeamJuly 27, 202613 min read
You added a second MX node, dropped HAProxy in front of them on port 25, and pointed DNS at the balancer. Within an hour the wheels come off. SPF starts passing for obviously spoofed mail. postscreen stops greylisting the botnet that hammered you yesterday. fail2ban's mail jail bans nothing, because every failed auth and every pipelining violation now comes from the same address. And every log line — every single one — reads connect from unknown[10.0.0.10], the balancer's internal IP.
Nothing is misconfigured in the usual sense. The problem is architectural: SMTP and IMAP are not HTTP. There is no X-Forwarded-For at the wire level of a mail protocol. When the balancer opens a fresh TCP connection to your backend, the backend's getpeername() returns the balancer, and every IP-based decision your mail stack makes is now made about the wrong host.
The fix is the PROXY protocol: a small header HAProxy prepends to the TCP stream that carries the original source IP and port through to Postfix and Dovecot, before a single byte of the SMTP banner or TLS handshake. This is the build-it guide.
Why the client IP vanishes (and what it takes down with it)
A layer-4 load balancer terminates the client's TCP connection and originates a new one to the chosen backend. The source address on that second connection is the balancer's, and nothing at the application layer preserves the original. HTTP deployments dodge this because the proxy injects
HAProxy + PROXY Protocol for SMTP & IMAP: Preserve Client IPs — EvilMail Blog
X-Forwarded-For
into the request; SMTP and IMAP have no such field. The peer address *is* the identity, and you just replaced it.
Here is what silently degrades the moment that happens:
SPF and HELO checks evaluate the balancer's IP against the sender's SPF record. Your balancer isn't in anyone's SPF, so results become noise — and permissive policies start passing spoofed senders.
DNSBL/RBL lookups (Spamhaus Zen, Barracuda, SpamCop) query the balancer's IP. It isn't listed, so every connection looks clean regardless of the real source's reputation.
postscreen loses its entire reason to exist. Pregreet tests, deep-protocol tests, and the DNSBL score are all computed against one internal IP, so whitelisting and greylisting decisions become meaningless.
Rate limiting — smtpd_client_connection_rate_limit, smtpd_client_message_rate_limit, and the anvil counters — collapses every client in the world into a single bucket. Either you throttle nobody or you throttle everybody.
fail2ban matches one IP forever. Its regexes fire, but the address they extract is the balancer, so the jail either bans your own load balancer (locking everyone out) or you disable it and eat the brute force.
Abuse forensics become impossible. Every incident ticket points at 10.0.0.10.
The non-solutions come up in every thread on this. DSR (direct server return) preserves the source IP but is asymmetric, fragile behind stateful firewalls, and a nightmare with TLS. TPROXY in the kernel genuinely works but is operationally heavy — you're managing routing marks, policy routes, and CAP_NET_ADMIN on the balancer. The PROXY protocol is the standardized answer that both Postfix and Dovecot understand natively, and it's the one I reach for every time.
How the PROXY protocol actually works
HAProxy sends a single header as the very first bytes of the TCP payload — before the SMTP 220 banner, before the TLS ClientHello, before anything. There are two versions:
v1 is human-readable ASCII: PROXY TCP4 203.0.113.7 10.0.0.11 56324 25\r\n — source IP, destination IP, source port, destination port. Easy to eyeball in a tcpdump.
v2 is binary: a 12-byte signature (\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A) plus a version/command byte and an address block. It parses cheaper and carries TLV extensions (for example, the TLS SNI or client cert info). Use send-proxy-v2.
The ordering is the part that trips everyone up, so say it plainly: the header precedes TLS. The backend reads and strips it before the handshake begins. That is exactly why it works transparently with STARTTLS on 587 and implicit TLS on 465/993 — by the time the ClientHello arrives, the header is already gone and the backend already knows the real peer.
One hard rule with no exceptions: a listener with proxy support enabled requires the header on every connection. A plain, headerless connection to that port is rejected. So any port you have turned into a proxy listener is now balancer-only — never point a monitoring script or a local relay directly at it.
Use mode tcp for every mail frontend and backend. HTTP mode parses and rewrites the byte stream — it will corrupt an SMTP dialog and mangle a TLS record. There is no scenario where mail belongs in HTTP mode.
Terminate TLS at the backends, not at HAProxy. Passthrough means Postfix and Dovecot keep their own certificates, SNI handling, and STARTTLS state machine, while HAProxy relays raw bytes plus the PROXY header. You avoid re-encrypting, avoid shipping private keys to the balancer, and avoid a whole class of cipher-mismatch bugs.
haproxy
defaults
mode tcp
option tcplog
timeout connect 5s
timeout client 1m
timeout server 1m
frontend smtp_in
bind 203.0.113.10:25
default_backend smtp_nodes
backend smtp_nodes
option smtpchk EHLO haproxy.check
server mx1 10.0.0.11:25 check send-proxy-v2
server mx2 10.0.0.12:25 check send-proxy-v2
frontend imap_in
bind 203.0.113.10:143
bind 203.0.113.10:993
timeout client 30m
default_backend imap_nodes
backend imap_nodes
option tcp-check
tcp-check expect string "* OK"
timeout server 30m
timeout tunnel 1h
server imap1 10.0.0.11:143 check send-proxy-v2
server imap2 10.0.0.12:143 check send-proxy-v2
# implicit-TLS: health-check the cleartext sibling
# server imaps1 10.0.0.11:993 check port 143 send-proxy-v2
Replicate the SMTP frontend/backend for 465 and 587, and the IMAP one for POP3 on 110/995 if you serve it. Two subtleties matter.
Health checks. For SMTP, option smtpchk EHLO haproxy.check opens a connection and expects a 2xx. For IMAP, option tcp-check with tcp-check expect string "* OK" matches the greeting. But on implicit-TLS ports (993, 465) there is no cleartext banner to match — the first bytes are a TLS handshake. Don't try to expect a string there; instead health-check the plaintext sibling with check port 143 (or check port 25) and let the TLS port ride on that verdict.
Timeouts. This is the one people get burned by. IMAP IDLE (RFC 2177) holds a TCP connection open, silent, for tens of minutes while the client waits for a new-mail push. The default timeout client/server 1m guillotines those idle sockets, and clients respond with a reconnect storm that looks like a small DDoS against your own IMAP. Set timeout client 30m, timeout server 30m, and timeout tunnel 1h on IMAP backends. SMTP is fine at the 1m default.
Postfix: teaching smtpd and postscreen to read the header
Postfix has two separate knobs, and using the wrong one silently does nothing. The rule: whichever service owns the connection first reads the header.
If you run postscreen on port 25 — and on a real MX you should — postscreen accepts the connection before smtpd exists, so it must parse the header. Use postscreen_upstream_proxy_protocol. For the submission services (587, 465) and the smtpd instance behind postscreen, use smtpd_upstream_proxy_protocol. Never set both on the same connection path.
bash
# /etc/postfix/master.cf
smtp inet n - y - 1 postscreen
-o postscreen_upstream_proxy_protocol=haproxy
submission inet n - y - - smtpd
-o smtpd_upstream_proxy_protocol=haproxy
smtps inet n - y - - smtpd
-o smtpd_upstream_proxy_protocol=haproxy
The only accepted value is haproxy — it auto-detects v1 and v2, so send-proxy and send-proxy-v2 both work against it. There is no proxy_protocol or v2 variant to type.
Two warnings. First, once a service has this set, direct headerless connections to it fail. If you have a local relay, a monitoring probe, or an application that talks to submission directly, give it a separate submission service on another port without the proxy option. Second — and this is the big one — Postfix does no source-IP validation on the header. It trusts whoever sends it. That is the security trap covered below.
Verify:
bash
postconf -Mf | grep proxy # confirm the -o overrides are live
# then watch a real delivery land:
tail -f /var/log/mail.log
# connect from mail.example.com[203.0.113.7] <-- real IP, not 10.0.0.10
Dovecot: haproxy listeners and the trust boundary
Dovecot handles this more safely than Postfix out of the box, because it ships a trust check. Enable haproxy = yes on the public listeners and declare which sources are allowed to send a header:
The semantics differ from Postfix in two important ways. Dovecot rejects a PROXY header from any source outside `haproxy_trusted_networks`, and it rejects a headerless connection on a proxy listener. So a listener is strictly balancer-only and strictly header-only — you can't accidentally leave a bypass open.
Because the public listeners now demand a header, keep a separate localhost listener without haproxy = yes for doveadm, LMTP/sieve delivery, and monitoring — otherwise those all break the moment you deploy. After this, login logs carry the real remote IP as rip=203.0.113.7, which is exactly the field Dovecot's fail2ban jail reads. Re-test that jail; it should start banning real offenders again.
The security trap: a spoofable header is a bypass
The PROXY header is attacker-controlled input. Anyone who can open a TCP connection to a proxy-enabled backend port can hand-write any source IP they like — say, a well-reputed address that sails past your RBLs and rate limits, or your own IP to whitelist themselves. It is a full authentication and reputation bypass in one line of printf.
Dovecot's haproxy_trusted_networks blocks this at the app layer. Postfix has no equivalent. So the firewall is not optional:
Bind backend proxy ports to the internal interface only, never 0.0.0.0.
With nftables/iptables, allow the proxy ports (25, 465, 587, 143, 993 on the backends) from the balancer IPs and drop everything else.
Better still, put the balancer-to-backend traffic on a private VLAN the internet can't route to at all.
If you run HA — keepalived floating a VIP across two HAProxy nodes — remember that *either* balancer can originate the backend connection on failover. Put both balancer IPs in the firewall allow-list and in Dovecot's haproxy_trusted_networks, or your standby will be firewalled off exactly when you need it.
Verify it end to end
bash
# 1. See the literal header hit the wire on a backend:
tcpdump -A -i any 'tcp port 25' | grep -A2 PROXY
# ...PROXY TCP4 203.0.113.7 10.0.0.11 56324 25
# 2. Prove the backend parses and logs a (test) forged IP:
printf 'PROXY TCP4 203.0.113.7 10.0.0.11 55000 25\r\nEHLO test\r\n' \
| nc 10.0.0.11 25
# mail.log -> connect from unknown[203.0.113.7]
# 3. Validate HAProxy config before reload:
haproxy -c -f /etc/haproxy/haproxy.cfg
Run step 2 from an allowed host. If it works from a *disallowed* host, your firewall is wrong and you have a live bypass.
Checklist
HAProxy mode tcp on every mail frontend/backend; TLS passthrough, never HTTP mode.
send-proxy-v2 on every backend server line.
SMTP: postscreen_upstream_proxy_protocol=haproxy when postscreen owns :25; -o smtpd_upstream_proxy_protocol=haproxy on submission/smtps. Never both on one path.
Dovecot: haproxy = yes on public listeners plus haproxy_trusted_networks set; keep a separate local listener without it for doveadm/sieve.
Backend proxy ports firewalled to balancer IPs only — mandatory, because Postfix does no source check on the header.
IMAP backends: timeout client/server 30m and timeout tunnel 1h so IDLE survives.
Health-check implicit-TLS ports (993/465) via their cleartext sibling with check port 143.
In HA, both HAProxy IPs in the firewall allow-list and the Dovecot trusted set.
The proof is in the logs: tcpdump shows PROXY TCP4 203.0.113.7 ... on the wire, mail.log reads connect from mail.example.com[203.0.113.7], Dovecot shows rip=203.0.113.7, and fail2ban starts banning the right addresses again. When all four line up, the client IP is preserved end to end and your SPF, DNSBL, postscreen, and rate limits are back to making decisions about the host that actually connected.