Migrating from SpamAssassin to rspamd: mapping rules, scores, and the performance you actually gain
A production walkthrough for moving a Postfix + amavis + SpamAssassin box to rspamd via the milter proxy — how to translate custom rules and scores, why SA's 5.0 threshold does not mean what you think it does under rspamd's action bands, and the real latency and throughput you gain.
EvilMail TeamJuly 20, 202613 min read
It always shows up the same way. A customer runs a newsletter blast, three thousand messages hit your inbound queue in a minute, and amavisd-new forks a fresh spamd child for every one of them. Each child drags a Perl interpreter and a Bayes lookup into memory, sits at 80–120 MB RSS, and your load average climbs past 8 while mail backs up in deferred. On paper the box has CPU to spare. It's dying because the architecture forks a heavyweight interpreter per message.
So you migrate to rspamd. Porting the scores is mechanical — a couple of hours of translation. The action model is conceptual, and it's where almost everyone quietly loses accuracy after the switch: they drop SpamAssassin's numbers into a system where those numbers mean something different. We're moving Postfix + amavisd-new + SpamAssassin to Postfix + rspamd (proxy worker in milter mode) + Redis, and the throughput win at the end is real and measurable.
Why rspamd is a different animal, not a drop-in
SpamAssassin is a per-message batch scorer. Postfix — through amavis or
SpamAssassin to rspamd Migration: Rules, Scores & Performance — EvilMail Blog
spamass-milter
— hands it a message,
spamd
forks a child, the child runs a pile of Perl regexes, does a Bayes lookup in Berkeley DB or SQL, sums a score, and returns a number. Everything after that (tag, quarantine, reject) is decided downstream by amavis or your MTA. SA doesn't sign anything, doesn't greylist, doesn't rate-limit. It scores and exits.
rspamd is a single event-driven C daemon that stays resident. Rules run through a Lua engine with Hyperscan-accelerated regex matching (PCRE with JIT as the fallback), so a hundred patterns are matched in one pass instead of sequentially. Bayes, fuzzy hashes, greylisting, rate limits and reputation all live in Redis. And critically, rspamd owns the verdict: it holds per-symbol scores *and* action thresholds, and it applies DKIM/ARC signing and DMARC checks inline. One daemon, one socket, one Redis.
That one difference — SA returns a number, rspamd returns a decision — is why greylisting and signing come "for free" after you migrate, and why the score mapping is not a copy-paste job.
Inventory what you actually run before touching anything
Don't install rspamd first. Audit the box you have, because half of it won't survive the move and you need to know which half.
bash
# custom rules and score overrides you have to port
ls -la /etc/spamassassin/*.cf
grep -rhE '^(header|body|rawbody|uri|meta|score|whitelist_from|blacklist_from)' \
/etc/spamassassin/
# size of the Bayes corpus you're leaving behind
sa-learn --dump magic
# sanity-check the current ruleset compiles
spamassassin --lint
Write down four things: your channel (spamass-milter or an amavis content_filter), your custom rules and their scores, your whitelist_from / blacklist_from entries, and whether you sign DKIM with opendkim today. The Bayes magic dump tells you the token count and how many spam/ham messages trained it. Set the expectation now: that corpus does not transfer. rspamd's Bayes is a different tokenizer in a different store. You'll retrain from your Junk folders or warm a fresh classifier — either is fine. Pretending you can import the Berkeley DB is not.
Install and wire rspamd into Postfix via the milter
Use the upstream repository, not your distro package. Debian and Ubuntu ship rspamd versions that lag by major releases, and the config schema moves between them. Rmilter is dead — it was retired years ago. The proxy worker in milter mode is how you connect to Postfix now.
The one rule that saves you grief: **never edit files directly under /etc/rspamd/*.conf**. Those are shipped defaults, and updates overwrite them. Overrides go in /etc/rspamd/local.d/ (merged into the defaults) or /etc/rspamd/override.d/ (replaces a whole section).
Point the proxy worker at itself in self-scan mode:
ini
# /etc/rspamd/local.d/worker-proxy.inc
milter = yes;
timeout = 120s;
upstream "local" {
default = yes;
self_scan = yes; # scan in-process, no separate normal-worker hop
}
Hand Postfix the milter and — this matters — tell it to fail open:
ini
# /etc/postfix/main.cf
smtpd_milters = inet:localhost:11332
non_smtpd_milters = inet:localhost:11332
milter_protocol = 6
milter_mail_macros = i {auth_authen}
milter_default_action = accept # rspamd down? mail still flows
Now retire the old path. Comment out the amavis content_filter and its smtp-amavis / 127.0.0.1:10025 entries in master.cf (or drop the spamass-milter line from smtpd_milters). Set a controller password so the web UI on port 11334 isn't wide open:
bash
rspamadm pw # paste the $2$... hash below
ini
# /etc/rspamd/local.d/worker-controller.inc
password = "$2$xxxxxxxx..."; # from rspamadm pw
Run rspamadm configtest && systemctl restart postfix rspamd and inbound mail is now scanned by rspamd. Nothing rejects yet — that's deliberate, and we get there safely.
Mapping SpamAssassin scores to rspamd actions — the part everyone gets wrong
Here's the mistake. In SpamAssassin, required_score 5.0 is *the* line — cross it and the message is spam, and whatever's downstream tags or rejects it. Engineers migrate, see that rspamd has a reject action, and set reject = 5. Two days later, legitimate mailing-list and forwarded mail is bouncing at the SMTP layer with no way for senders to recover, because a 5-point message under rspamd's default metric is a perfectly ordinary "add a header and deliver" message.
rspamd doesn't have one threshold. It has four action bands, each with its own score, and the sane defaults are:
ini
# /etc/rspamd/local.d/actions.conf
greylist = 4; # defer, ask the sender to retry
add_header = 6; # deliver, tag X-Spam headers <-- SA's real 5.0 lives HERE
rewrite_subject = 8; # optional: prefix "[SPAM]"
reject = 15; # hard 5xx at SMTP
SpamAssassin's required_score 5.0 maps to rspamd's `add_header 6`, not to reject. rspamd's reject sits at 15 because its symbols stack differently — a genuinely garbage message easily piles up 15+ points across SPF fail, DMARC fail, dynamic-IP RBL hits and Bayes, while borderline mail lands in the 6–10 band where a header, not a bounce, is the right call.
Per-symbol scores — SA's score RULE 3.5 lines — go in local.d/groups.conf:
-- /etc/rspamd/rspamd.local.lua
config['regexp']['LOCAL_VIAGRA'] = {
re = 'Subject=/viagra/i',
score = 5.0,
description = 'ported from SA LOCAL_VIAGRA',
}
Whitelists are the bigger upgrade. Instead of scattering whitelist_from lines, use the multimap module with a real map file you can edit without a reload dance:
ini
# /etc/rspamd/local.d/multimap.conf
LOCAL_WL_FROM {
type = "from";
map = "/etc/rspamd/maps.d/whitelist_from.map";
score = -8.0; # negative score, not a hard bypass
description = "trusted senders";
}
For Bayes, don't try to import the BerkeleyDB. Warm a fresh classifier from mail you already have. If your users' junk lives in Maildir, that's a free labelled corpus:
bash
# feed known spam and ham straight into Redis-backed Bayes
find /var/mail/vhosts/*/*/Maildir/.Junk/cur -type f -exec rspamc learn_spam {} +
find /var/mail/vhosts/*/*/Maildir/cur -type f -exec rspamc learn_ham {} +
Bayes needs roughly 200 spam and 200 ham before it contributes to scoring, so warm it before cutover, not after. Then let it maintain itself with autolearn = true in local.d/classifier-bayes.conf. You also get fuzzy_check — collaborative fuzzy hashing of known spam bodies — which SA never had a real equivalent for.
Turn on what SpamAssassin couldn't do
This is where the migration pays for itself instead of just breaking even. Retire opendkim and sign inside rspamd:
The same daemon runs the dmarc module (with aggregate report generation), ratelimit, replies (so answers to mail you sent aren't greylisted), and greylisting as a built-in symbol — you can uninstall postgrey entirely. One config tree, one process, instead of opendkim + postgrey + amavis + spamd stitched together with sockets.
Verify, measure, and cut over safely
Never flip reject on blind. Run rspamd alongside your existing filter for a few days and watch what it *would* do. With reject = 15 set and real mail flowing, every scanned message carries the evidence — you read the verdict without letting it act destructively yet:
bash
# score a sample offline
rspamc /var/mail/vhosts/evilmail.pro/test/Maildir/new/162xxxx.eml
# on delivered mail, read the verdict rspamd wrote
grep -h "X-Spamd-Result" /var/mail/vhosts/evilmail.pro/*/Maildir/{new,cur}/* | head
Compare rspamd's X-Spamd-Result action against SA's X-Spam-Status on the same messages. Where they disagree, tune the symbol scores — don't touch the action thresholds. Watch throughput on the web UI at http://host:11334. When the verdicts line up, you're done.
The numbers that make the exercise worth it: SA+amavis runs 250–400 ms per message with its Perl fork and Bayes lookup, and each spamd/amavis child holds 60–120 MB. rspamd scans in 10–30 ms from a single resident daemon that stays around 150–250 MB total regardless of concurrency. A small VPS that saturated the forking pipeline at 10–20 msg/s sustains 100+ msg/s on the same hardware, and load during a mailing-list blast drops from 8+ to under 1.
Your rollback is one line. If something's wrong, swap smtpd_milters back to your spamass-milter / amavis socket in main.cf, reload Postfix, and you're on the old path in seconds. Keep the amavis config staged, don't delete it, until you've run reject-on in production for a week.
Migration checklist
Official rspamd repo installed (not distro); redis-server running and reachable on 127.0.0.1:6379
Proxy worker in milter mode; Postfix smtpd_milters = inet:localhost:11332 with milter_default_action = accept
amavis / spamass-milter removed from master.cf and smtpd_milters
actions.conf thresholds set (add_header 6, reject 15) — SA's 5.0 mapped to add_header, not reject
Custom SA rules ported to regexp / Lua with scores; whitelist_from moved to multimap maps
Bayes warmed from Junk/Maildir corpus (200+ spam, 200+ ham); autolearn = true
DKIM signing in dkim_signing.conf; opendkim and postgrey retired; a mail-tester.com test passes
Parallel-run verdict comparison done (SA vs X-Spamd-Result) before enabling reject
Controller password set via rspamadm pw; web UI on 11334 monitored
amavis config staged for a one-line rollback
Before: ~300 ms per message, a forked Perl interpreter and 80+ MB per child, choking at ~15 msg/s. After: sub-30 ms in one ~200 MB daemon, comfortably past 100 msg/s, with DKIM/ARC/DMARC/greylisting folded into the same process for free.