Rspamd + Postfix over Milter: the Proxy Worker Done Right
Most guides bolt rspamd onto Postfix and stop at "it works." The correct 2026 default is the milter path through the rspamd proxy worker in self-scan mode: one ingress that scans inbound and DKIM-signs outbound. Here's the exact wiring, the macros that silently kill scoring, and how to read the verdict from headers instead of guessing.
EvilMail TeamJuly 21, 202613 min read
A message hits your MX. Before Postfix writes a queue entry it can never cleanly bounce, you want that message scanned, scored, maybe greylisted, maybe rejected outright — while the sending server is still on the line and can be handed a real 5xx. That timing is the entire decision. Do it after the queue and every rejection becomes a bounce to a forged return-path: backscatter you'll spend the next month explaining to blocklist operators. Do it in-line, during the SMTP conversation, and a spam verdict turns into a 550 the remote MTA has to deal with itself.
That in-line hook is the milter protocol, and rspamd's own recommended way to use it is the proxy worker. If you are still running spamass-milter in front of SpamAssassin, or the old amavisd-new sandwich with a separate opendkim daemon, you are running three processes to do what one socket does now. This is the wiring, the failure modes, and how to actually read what rspamd decided.
Milter beats the alternatives, and here's the mechanism
A milter is a callout. During the SMTP transaction Postfix connects to a milter socket and consults it at each protocol phase — CONNECT, HELO, MAIL FROM
,
RCPT TO
,
DATA
, and end-of-message. At each stop the filter can accept, reject, tempfail, quarantine, or ask Postfix to add headers. The one that matters is end-of-message: rspamd has now seen the full body and every macro, computes a score, and returns a verdict Postfix enforces *before* it acknowledges
DATA
with a
250
.
Weigh the alternatives honestly. spamass-milter is a second daemon that shells out to SpamAssassin, has no native DKIM signing, and historically passes fewer macros, so its authentication context is weaker. LDA-time filtering (sieve, procmail) runs *after* Postfix has already accepted the message — far too late to reject, so your only options are silent discard or a spam folder, both of which cost you legitimate mail you can never explain. The milter path is the only one where "this is spam" and "the sender learns about it right now" are the same event.
The proxy worker is the front door
This is the single most misunderstood piece of an rspamd deployment. rspamd is not one process; it is a set of workers, each on its own port, and only one of them speaks milter. Postfix talks to the proxy worker on `11332` and nothing else.
`11332` — proxy worker (milter): the front door for Postfix.
`11333` — normal / scan worker (HTTP): does the actual scanning over rspamd's HTTP protocol. Postfix never connects here.
`11334` — controller (WebUI, learn, stats): the dashboard and the rspamc learn_spam / learn_ham endpoint.
`11335` — fuzzy_storage: the fuzzy-hash database.
There are two topologies, and self_scan is the switch between them.
For a single-box mail server — almost everyone — you want self_scan = yes. The proxy scans the message itself on the in-process scan path instead of proxying the milter session to a remote node. The config lives in /etc/rspamd/local.d/worker-proxy.inc:
nginx
milter = yes;
timeout = 120s;
upstream "local" {
default = yes;
self_scan = yes; # single box: scan in the proxy, don't forward to 11333
}
Drop self_scan, add hosts = "...", and the proxy becomes a pure router that forwards the milter session to a scan cluster on 11333 for horizontal scale. One trap survives even in self-scan mode: you still need the normal worker running, because bayes learning and rspamc stat go through it. Postfix just never touches it directly.
Wiring Postfix to the socket
Everything on the Postfix side lives in main.cf — the minimum that works, plus the parts most people forget:
milter_protocol = 6 is the correct value for modern Postfix (2.6+). Fall back to protocol 2 or 4 and the milter still connects and appears to work while silently dropping macros — which degrades scoring in ways that are miserable to debug. Use inet:localhost:11332 unless you have a reason to prefer a unix socket; the socket saves a TCP round trip, but then you have to get the filesystem permissions right so postfix can reach a path rspamd owns.
milter_default_action is the fail-mode decision, and it is a real policy choice, not a default to leave alone. With accept, if rspamd is down or times out, Postfix passes the mail through unscanned — you fail open, never lose legitimate mail, and eat whatever spam arrives during the outage. With tempfail, Postfix hands back a 451 and the sender retries later, so nothing goes unscanned, but a prolonged rspamd outage backs your queue up and delays real mail. Most operators should run accept and *alert on rspamd being down* rather than let a filter crash become a mail outage. If you handle mail where an unscanned message is genuinely worse than a delayed one, choose tempfail and mean it.
Pointing non_smtpd_milters at the same socket is deliberate: locally-submitted and authenticated-submission mail flows through rspamd too, which is how outbound gets DKIM-signed. The catch is that authenticated mail must never be spam-rejected — which brings us to the macros.
The macros rspamd needs — the silent scoring killer
Postfix does not volunteer everything to a milter. You have to name the macros you want passed, and if you skip the authentication ones, rspamd treats your own logged-in users as strangers off the street. The critical one is {auth_authen} — the SASL login name. Without it, the settings module rule that whitelists authenticated senders never matches, and your customers' outbound mail gets scored, greylisted, and occasionally rejected as if it came from a random botnet.
ini
milter_mail_macros = i {auth_authen} {auth_author} {auth_type} \
{mail_addr} {client_addr} {client_name} {sasl_username}
milter_end_of_data_macros = i {auth_authen}
With {auth_authen} flowing, a settings rule like this actually fires:
Read it as: "if the session authenticated, disable the reject and greylist actions" — outbound mail from your users gets DKIM-signed and delivered, never bounced by your own filter. rspamd also leans on {daemon_name}, {v}, the queue id i, {cert_subject}, and the address macros to build SPF/DKIM/DMARC context and per-user settings. If your scores look random, check the macros before you touch a single symbol weight.
Reading the verdict: actions, thresholds, headers
rspamd maps a numeric score to an action. The defaults, tunable in /etc/rspamd/local.d/actions.conf, are guidelines, not scripture:
Below greylist it's "no action." These numbers are worth tuning against your own corpus once you have volume — reject at 15 is deliberately conservative so you don't reject on a single noisy symbol.
The verdict is visible in headers rspamd injects via the milter_headers module. On any delivered message, X-Spamd-Result is the one to read: it lists every symbol that fired with its individual weight and the total, so you can see *why* a message scored what it did instead of guessing. You'll also see X-Spam (Yes/No), X-Spam-Status, and X-Rspamd-Queue-Id for correlating a header back to a log line. A raw dump:
Same path, outbound: DKIM signing without a second daemon
Because the proxy worker sees submission traffic — via non_smtpd_milters, keyed on {auth_authen} — the dkim_signing module signs on the way out over the exact same socket. This replaces opendkim entirely: one daemon, one milter, no separate signing socket to keep in sync.
Then publish the public half at 2026a._domainkey.evilmail.pro as a TXT record — v=DKIM1; k=rsa; p=MIIB... (the keygen prints it, split across quoted strings if the 2048-bit key overflows 255 chars). Once it validates, systemctl stop opendkim && systemctl mask opendkim; it is dead weight.
Verify it end to end
Run through this before you call it done:
1.Config parses:rspamadm configtest — must say syntax OK.
2.Workers bound:ss -lntp | grep -E '1133[2-5]' — you should see all four ports listening. If 11332 is missing, Postfix has nothing to talk to.
3.Postfix sees the milter:postconf -n | grep milter — confirm the socket, milter_protocol = 6, and your macro line.
4.Forced reject: send a test message containing the GTUBE string XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X and watch tail -f /var/log/rspamd/rspamd.log | grep -E 'action:|reject'. You should see action: reject and, in the Postfix log, a 550 returned at DATA.
5.Headers on ham: send a normal message and confirm X-Spamd-Result landed with a total well under 15.
6.Stats move:rspamc stat — scanned/learned counters and the actions histogram should be incrementing.
7.Auth is exempt: submit an authenticated message and confirm it is not rejected and is DKIM-signed. If it gets scored like a stranger, your {auth_authen} macro isn't reaching rspamd.
8.Offline sanity:rspamc symbols < message.eml dumps the full scoring for any saved .eml without re-sending it.
One operational note to close. The failure you will actually hit in production is not a mis-weighted symbol — it's a missing macro or a fail-open timeout. Never edit /etc/rspamd/rspamd.conf directly; it gets overwritten on upgrade, so override in local.d/*.inc and override.d/. And because milter_default_action = accept means a crashed rspamd silently passes every message unscanned, your monitoring should alert on the daemon being down and on your fail-open state — not on spam scores.