Starting with version 2, ulaknode replaces rspamd + Redis with a set of single-purpose, standard tools: OpenDKIM, OpenDMARC, SpamAssassin (+ spamass-milter), policyd-spf, and Postgrey. ClamAV is unchanged.
This is not a drop-in image swap on an existing deployment — a few things need manual attention because of what already lives in your persistent volumes.
1. Update docker-compose.yml
Replace the ulak_rspamd volume with the five new ones:
volumes:
- ulak_mail:/var/mail
- ulak_logs:/var/log
- ulak_clamav:/var/lib/clamav
- ulak_postfix:/etc/postfix
- ulak_dovecot:/etc/dovecot
- ulak_opendkim:/etc/opendkim
- ulak_opendmarc:/etc/opendmarc
- ulak_spamassassin:/etc/spamassassin
- ulak_policyd_spf:/etc/postfix-policyd-spf-python
- ulak_postgrey:/etc/postgrey
- ulak_certs:/etc/ssl/mail
# ... cert bind mounts unchanged
volumes:
ulak_mail:
ulak_logs:
ulak_clamav:
ulak_postfix:
ulak_dovecot:
ulak_opendkim:
ulak_opendmarc:
ulak_spamassassin:
ulak_policyd_spf:
ulak_postgrey:
ulak_certs:
The five new volumes don't exist yet on your host, so the entrypoint's seed-on-first-boot logic populates them from the image defaults automatically — no manual step needed there. ulak_mail, ulak_logs, ulak_clamav, and ulak_dovecot carry over untouched (nothing about them changed), and a warm ulak_clamav volume means no virus-database redownload.
2. Merge main.cf/master.cf by hand — the one real gotcha
The entrypoint only seeds /etc/postfix from image defaults once, guarded by a .seeded marker file that already exists in your live ulak_postfix volume. Swapping the image alone will not update your live main.cf/master.cf — Postfix would come back up still pointed at rspamd's (now absent) milter socket, with none of the new SPF/greylist restrictions or milter chain wired in.
Before starting the new image against your existing ulak_postfix volume:
- Back up the volume (copy its
_datadirectory, or your usual volume backup method). - Diff your live
main.cf/master.cfagainst this repo'spostfix/conf/main.cfandpostfix/conf/master.cfto see what's new: thesmtpd_recipient_restrictionsblock (SPF + greylist checks), the milter configuration block, and thesmtpd_milters=overrides plus thepolicyd-spfspawn service inmaster.cf.
RBL/DNSBL blocklist checking was tried and dropped — a naivereject_rbl_client zen.spamhaus.orgwill silently reject all mail if your resolver gets classified by Spamhaus as an open/shared resolver (very easy to hit behind a corporate or shared DNS setup), so it's not part of the default config right now. - Apply those specific changes into the live files. Don't touch
virtual_domains,virtual_mailboxes, orvirtual_aliasesin the same directory — those hold your actual domain/mailbox data and are unaffected by this change.
myhostname/myorigin/mydomain get rewritten from MAIL_HOSTNAME on every boot regardless, so you don't need to worry about those.
3. Carry over DKIM keys — reuse them, don't regenerate
DKIM private keys are standard PEM-encoded RSA keys under the hood — the same key material works under both rspamd and OpenDKIM, only the file layout and table format differ. Since your existing keys already have published, propagated DNS TXT records, regenerating them would force a DNS republish and a DKIM-verification gap while it propagates. Reuse the existing key material instead:
# From the old container/volume, per domain+selector:
docker exec <old-container> cat /etc/rspamd/dkim/<domain>/<selector>.key \
> <selector>.private
# Into the new container:
docker exec <new-container> mkdir -p /etc/opendkim/keys/<domain>
docker cp <selector>.private <new-container>:/etc/opendkim/keys/<domain>/<selector>.private
docker exec <new-container> chown -R opendkim:opendkim /etc/opendkim/keys/<domain>
docker exec <new-container> chmod 750 /etc/opendkim/keys/<domain>
docker exec <new-container> chmod 640 /etc/opendkim/keys/<domain>/<selector>.private
ulaknode-dkim currently only supports generate (which refuses if a key already exists, by design) — there's no import subcommand yet for “register a key I already have.” After copying the file into place, you need to manually rebuild /etc/opendkim/KeyTable and /etc/opendkim/SigningTable to match (one selector._domainkey.domain line and one *@domain line per key — see the existing entries in those files for the exact format), then:
docker exec <new-container> ulaknode-service reload opendkim
If you'd rather have a proper ulaknode-dkim import command instead of hand-editing those tables, ask — it's a reasonable, still-unbuilt addition.
4. Test before cutting over
Since this is the same MX record rather than a blue/green swap, validate against copies of your volumes in a throwaway container first:
docker exec <new-container> ulaknode-service status opendkim
docker exec <new-container> ulaknode-service status opendmarc
docker exec <new-container> ulaknode-service status spamassassin
docker exec <new-container> ulaknode-service status spamass-milter
docker exec <new-container> ulaknode-service status postgrey
docker exec <new-container> ulaknode-service status clamav
docker exec <new-container> ulaknode-service status clamav-milter
Then send a real test message through port 25/587 and confirm the delivered message has Received-SPF:, Authentication-Results: (DKIM/DMARC), and X-Spam-Status: headers, and that clamd/clamav-milter are reachable.
5. Clean up
Once you've confirmed the new stack is working in production, the old ulak_rspamd volume is no longer referenced by anything and can be removed:
docker volume rm ulak_rspamd