Ulaknode version 2 upgrade

Starting with this release, 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:

  1. Back up the volume (copy its _data directory, or your usual volume backup method).
  2. Diff your live main.cf/master.cf against this repo's postfix/conf/main.cf and postfix/conf/master.cf to see what's new: the smtpd_recipient_restrictions block (SPF + greylist checks), the milter configuration block, and the smtpd_milters= overrides plus the policyd-spf spawn service in master.cf. (If you're also picking up 2.1.0's DNSBL/postscreen and Fail2ban changes in the same pass, see the dedicated section below instead of hand-merging those into this diff — same idea, but with their own volumes and config blocks.)
  3. Apply those specific changes into the live files. Don't touch virtual_domains, virtual_mailboxes, or virtual_aliases in 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 all

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

Upgrading to 2.1.0

2.1.0 adds two independent features, each needing its own manual merge into existing volumes: an in-container Fail2ban service, and DNSBL blocklist checking via postscreen backed by a dedicated Unbound resolver. Both touch files inside /etc/postfix, which is seeded from image defaults once (guarded by a .seeded marker) — an existing volume won't pick up either automatically on a plain image swap.

Fail2ban

Bans brute-force SMTP/IMAP clients at the Postfix/Dovecot application layer instead of iptables — see docs/fail2ban.md for how it works. This also touches /etc/dovecot, seeded the same way.

  1. Add the new volume to docker-compose.yml:

        volumes:
          # ... existing volumes unchanged
          - ulak_fail2ban:/etc/fail2ban
    
    volumes:
      # ...
      ulak_fail2ban:
    

    This one's brand new, so it seeds itself correctly from image defaults on first boot — no manual step needed for it specifically.

  2. In your existing ulak_postfix volume, create the (empty) cidr ban table Postfix expects once the new restriction below is in place — main.cf referencing a cidr: table that doesn't exist yet stops Postfix from starting:

    docker exec <container> touch /etc/postfix/fail2ban_clients
    

    Then add this restriction to main.cf (see this repo's postfix/conf/main.cf for exact placement, ahead of smtpd_recipient_restrictions):

    smtpd_client_restrictions =
        check_client_access cidr:/etc/postfix/fail2ban_clients
    
  3. In your existing ulak_dovecot volume, add this line to dovecot.conf (after the existing conf.d/*.conf include):

    !include_try /etc/dovecot/fail2ban.d/*.conf
    
  4. Reload and confirm:

    docker exec <container> ulaknode-service reload postfix
    docker exec <container> ulaknode-service reload dovecot
    docker exec <container> ulaknode-service status fail2ban
    

DNSBL / postscreen / Unbound

Checks inbound connections against DNSBL blocklists (Spamhaus Zen, SpamCop) via postscreen, before a real smtpd process spawns, backed by a dedicated recursive resolver (Unbound) instead of a shared one — see docs/self-hosted.md for why that resolver matters. This is a bigger master.cf change than Fail2ban's (a service gets renamed and split), so diff carefully rather than hand-copying line by line.

  1. Add the new volume to docker-compose.yml:

        volumes:
          # ... existing volumes unchanged
          - ulak_unbound:/etc/unbound
    
    volumes:
      # ...
      ulak_unbound:
    

    Brand new, so it seeds itself correctly from image defaults on first boot.

  2. In your existing ulak_postfix volume's master.cf, replace the smtp inet ... smtpd service block (port 25) with this repo's version: the smtp service becomes postscreen, a new smtpd pass service holds all the -o overrides that used to live on the smtp line, and two new helper services (dnsblog, tlsproxy) get added. See this repo's postfix/conf/master.cf for the exact block — copy it over rather than editing in place, the structure changed too much for a line-level diff to be practical.

  3. In main.cf, add the postscreen_dnsbl_sites / postscreen_dnsbl_threshold / postscreen_dnsbl_action block — see this repo's postfix/conf/main.cf for the exact settings and placement.

  4. Reload and confirm:

    docker exec <container> ulaknode-service restart postfix
    docker exec <container> ulaknode-service status unbound
    docker exec <container> postfix check
    docker exec <container> dig +short @127.0.0.1 example.com
    

    A working setup shows unbound: RUNNING, a clean (silent) postfix check, and an actual A record from the dig query.