Making Rspamd's Blocklists Actually Work: A Private Unbound Resolver + Spamhaus DQS

Here’s a failure mode that hides in plain sight: if your spam filter resolves DNS through a public resolver like Google or Cloudflare, most DNS blocklists silently refuse to answer it. Spamhaus, URIBL, SURBL and friends all do this. Your filter keeps running, adds a few headers, and quietly lets listed senders through — because the lookups that should have flagged them never returned a useful answer.

The fix has three parts: run your own recursive resolver, subscribe to Spamhaus’s free Data Query Service (DQS), and set a reject policy that’s aggressive on definitive signals without inviting false positives. This post is the complete, reproducible setup. My stack is Postfix + Dovecot + Rspamd + ClamAV on MicroShift, managed with Flux; domains, keys and IPs are anonymized, but everything else is exact.


Why a private resolver

The public-resolver block is easy to confirm. Ask Google for a Spamhaus test point:

$ dig @8.8.8.8 2.0.0.127.zen.spamhaus.org +short
127.255.255.254          # Spamhaus's "you're using a public/open resolver" sentinel

127.255.255.254 means refused. URIBL returns 127.0.0.1 for the same reason. So a config like this — common, and the trap — disables your blocklists without any error:

# DON'T: Rspamd querying RBLs through public DNS
dns { nameserver = ["8.8.8.8", "8.8.4.4"]; }

This is not about your server’s IP being listed; it’s about which resolver asks. The answer is a resolver that recurses from the root itself, so the query reaches the authoritative blocklist servers directly. The same test through a private recursive Unbound returns a real listing:

$ dig @10.43.0.53 2.0.0.127.zen.spamhaus.org +short
127.0.0.2
127.0.0.4
127.0.0.10               # real ZEN test listing — answered, not blocked

1. Deploy a recursive Unbound

Use an image that genuinely recurses from the root (not a DNS-over-TLS forwarder — those just relay to Cloudflare and re-create the exact public-resolver problem) and runs unprivileged (no chroot/mknod, which fails under restrictive security contexts). klutchell/unbound is distroless and does both; it recurses out of the box and only answers internal networks, so no custom config is needed for the blocklist job. There’s one catch once Rspamd uses it as its only resolver — it then can’t resolve your in-cluster Redis/ClamAV either. That bit me two days later; the fix is a small drop-in covered in the update below.

unbound-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: unbound
  namespace: mailstack
spec:
  replicas: 1
  selector:
    matchLabels: { app: unbound }
  strategy: { type: Recreate }
  template:
    metadata:
      labels: { app: unbound }
    spec:
      serviceAccountName: mailstack          # anyuid is enough; ClusterIP only, no hostPort
      containers:
      - name: unbound
        image: docker.io/klutchell/unbound@sha256:4718d579152d49a3eaf7cbc1ce67f4e2e0d6f396be8f604a2416a94e7a250be8
        ports:
        - { containerPort: 53, name: dns-udp, protocol: UDP }
        - { containerPort: 53, name: dns-tcp, protocol: TCP }
        resources:
          requests: { memory: 32Mi }
          limits:   { memory: 128Mi }
        livenessProbe:  { tcpSocket: { port: 53 }, initialDelaySeconds: 10, periodSeconds: 30 }
        readinessProbe: { tcpSocket: { port: 53 }, initialDelaySeconds: 5,  periodSeconds: 20 }

Distroless means no shell in the container, so the health probe must be tcpSocket, not an exec of dig/drill.

unbound-service.yaml — pin a static ClusterIP. Rspamd’s dns.nameserver wants an IP, not a hostname (the resolver can’t resolve its own name — chicken and egg), so a stable address matters:

apiVersion: v1
kind: Service
metadata:
  name: unbound
  namespace: mailstack
spec:
  type: ClusterIP
  clusterIP: 10.43.0.53                       # a free address in your service CIDR
  selector: { app: unbound }
  ports:
  - { port: 53, targetPort: 53, protocol: UDP, name: dns-udp }
  - { port: 53, targetPort: 53, protocol: TCP, name: dns-tcp }

2. Point Rspamd at it

# options.inc
dns { nameserver = ["10.43.0.53"]; }

That single line reactivates URIBL, SURBL, Mailspike and DBL. Spamhaus needs one more step.


3. Spamhaus DQS

Even a private resolver hits limits on Spamhaus’s free public zones from a datacenter IP. The supported path is the free Data Query Service: sign up, get a key, and query private zones like <key>.zen.dq.spamhaus.net. Spamhaus ships ready-made Rspamd config at github.com/spamhaus/rspamd-dqs. Take two files from its 3.x directory:

  • rbl.conf — zone definitions. Contains your key → keep it out of git, in a Secret.
  • rbl_group.conf — symbol scores only, no key → safe to commit.
sed 's/your_DQS_key/<YOUR_DQS_KEY>/g' rbl.conf > rbl.conf.real
oc -n mailstack create secret generic rspamd-spamhaus-dqs \
   --from-file=rbl.conf=rbl.conf.real          # never commit this

Mounting caveat (CRI-O): you can’t subPath-mount a Secret file into a directory that’s already mounted from a ConfigMap — CRI-O rejects it with Not a directory. The robust pattern is an initContainer that assembles local.d into an emptyDir: copy the ConfigMap files and the Secret’s rbl.conf into one writable volume, then mount that as local.d.

# rspamd-deployment.yaml (excerpt)
spec:
  template:
    spec:
      initContainers:
      - name: populate-local-d
        image: docker.io/rspamd/rspamd:4.1.1
        command: ["/bin/sh", "-c",
          "cp -L /cm/*.conf /cm/*.inc /work/ && cp -L /dqs/rbl.conf /work/rbl.conf"]
        volumeMounts:
        - { name: config,       mountPath: /cm,  readOnly: true }   # the ConfigMap
        - { name: spamhaus-dqs, mountPath: /dqs, readOnly: true }   # the Secret
        - { name: local-d,      mountPath: /work }                  # shared emptyDir
      containers:
      - name: rspamd
        volumeMounts:
        - { name: local-d, mountPath: /etc/rspamd/local.d }         # assembled dir
        - { name: config,  mountPath: /etc/rspamd/rspamd.local.lua, subPath: rspamd.local.lua }
      volumes:
      - { name: config,       configMap: { name: rspamd-config } }
      - { name: spamhaus-dqs, secret: { secretName: rspamd-spamhaus-dqs } }
      - { name: local-d,      emptyDir: {} }

A ConfigMap change alone won’t restart the pod, so after Flux reconciles: oc rollout restart deploy/rspamd. Confirm the chain end to end:

$ oc -n mailstack exec deploy/rspamd -- rspamadm configtest
syntax OK
$ ... | rspamc | grep Symbol
Symbol: DBL_SPAM (7.00)[dbltest.com:url]      # Spamhaus DBL via DQS via Unbound

4. A reject policy that won’t bite you

Working lookups are only half the job; the other half is reject vs. tag, and that’s where false-positive (FP) risk lives — a bounced invoice is far worse than a tagged spam. Two principles:

  • Lowering the global reject threshold punishes every email and invites FPs. Don’t.
  • Instead, raise scores only for signals that implicate the sender itself, and leave softer signals as header-only.

Concretely, in rbl_group.conf (the score file — no key, safe in git):

symbols = {
    "RBL_SPAMHAUS_SBL" { weight = 15.0; }   # sender IP on Spamhaus SBL  -> reject
    "RBL_SPAMHAUS_XBL" { weight = 15.0; }   # sender IP exploited/infected -> reject
    "RBL_DBL_SPAM"     { weight = 15.0; }   # sender domain malicious    -> reject
    "RBL_DBL_PHISH"    { weight = 15.0; }
    "RBL_DBL_MALWARE"  { weight = 15.0; }
    "RBL_DBL_BOTNET"   { weight = 15.0; }
    # left as header-only on purpose: PBL, ZRD, body-URL DBL, ABUSED_*
}

With a reject threshold of 15, a single weight-15 hit rejects on its own. The rationale, by signal:

SignalMeaningPolicyWhy
RBL_SPAMHAUS_SBL / XBLsender IP is a confirmed spam source / compromised hostrejectlegitimate servers are never listed → ~zero FP
RBL_DBL_{SPAM,PHISH,MALWARE,BOTNET}the sending domain (rdns/helo/from) is maliciousrejectidentifies the sender, not just content
RBL_SPAMHAUS_PBLdynamic / residential IPheaderlegit mail can originate behind these
RBL_ZRD_*brand-new domainheadernew ≠ malicious
DBL_* (URL in body)a bad link in the bodyheaderdon’t reject a whole mail over one quoted link
RBL_DBL_ABUSED_*hijacked legitimate domainheaderhigh FP risk

Two practical notes:

  • Rspamd loads local.d files alphabetically, and rbl_group.conf loads after metrics.conf, so it wins. Put score overrides there, or they get clobbered.
  • Verify the effective weights live: oc exec deploy/rspamd -- rspamc counters | grep RBL_SPAMHAUS_SBL.

5. Verify with Spamhaus BLT

Don’t trust synthetic rspamc scans alone — they can’t reproduce the real SMTP path (rdns/helo/envelope) that the strongest RBL_* symbols depend on. Spamhaus runs a Blocklist Tester that emails a battery of cases (SBL, XBL, PBL, DBL, ZRD via IP, EHLO, envelope-from and body). With the policy above, the log shows the definitive cases rejected at SMTP time and the soft ones tagged — exactly as intended:

from=<test@xbl-dqs.blt.spamhaus.net>  action=reject  score=25.10
  [RBL_SPAMHAUS_XBL(15.00){...:from}, SPAMHAUS_ZEN(7.00){...:from}]
from=<test@dbl-dqs.blt.spamhaus.net>  action=reject  score=25.10
  [RBL_DBL_SPAM(15.00){...:rdns}, DBL_SPAM(7.00){...:from_smtp,rdns,from_mime}]
from=<test@pbl-dqs.blt.spamhaus.net>  action=add header  score=12.10   # by design
from=<test@zrd-dqs.blt.spamhaus.net>  action=add header  score=10.10   # by design

Update (2026-06-28): the catch, cluster-internal names stop resolving

Two days after shipping this, Rspamd’s logs were filling up every ten seconds:

lua_redis.lua:1432: skipping SCRIPT LOAD for upstream redis: address not resolved yet

Everything looked healthy — both pods Running, no crashes — but Rspamd never loaded its Lua scripts into Redis, so everything Redis-backed was silently dead: history, the Bayes statistics, rate limiting, mx_check. ClamAV (clamav:3310) hung on the same thing.

The cause is the flip side of “recurses from the root”: Unbound has no idea what redis.mailstack.svc.cluster.local is — that’s a CoreDNS zone, and Unbound never forwards anything. And because Rspamd’s async resolver uses only dns.nameserver and doesn’t append search domains, even the short name redis fails twice over. (At the OS level it was fine — the pod’s /etc/resolv.conf still points at CoreDNS with the search list — but Rspamd’s own resolver bypasses all of that.)

The honest fix keeps the recursive-for-RBLs benefit and just teaches Unbound to forward the cluster zones to CoreDNS via a stub-zone. klutchell/unbound includes any *.conf from /etc/unbound/custom.conf.d/ with include-toplevel, so it’s a drop-in ConfigMap — no need to replace the image’s tuned defaults:

# cluster-dns.conf  (mounted at /etc/unbound/custom.conf.d/cluster-dns.conf)
server:
    # the image sets `private-address: 10.0.0.0/8` (rebind protection), which would
    # strip the 10.x ClusterIP answers — exempt the cluster zone:
    private-domain: "cluster.local."
    # DNSSEC validation is on (auto-trust-anchor-file: root.key); cluster.local has no
    # DS records, so mark it insecure or every lookup is SERVFAIL:
    domain-insecure: "cluster.local."
    domain-insecure: "42.10.in-addr.arpa."     # pod CIDR  10.42.0.0/16
    domain-insecure: "43.10.in-addr.arpa."     # service CIDR 10.43.0.0/16
    # lift Unbound's built-in RFC1918-reverse blocks so the stubs win:
    local-zone: "cluster.local." nodefault
    local-zone: "42.10.in-addr.arpa." nodefault
    local-zone: "43.10.in-addr.arpa." nodefault

stub-zone:
    name: "cluster.local."
    stub-addr: 10.43.0.10                       # your CoreDNS / kube-dns ClusterIP

# Only the cluster's reverse ranges go to CoreDNS — NOT all of in-addr.arpa, so
# external PTR lookups (which spam checks rely on) keep recursing normally:
stub-zone: { name: "42.10.in-addr.arpa."; stub-addr: 10.43.0.10; }
stub-zone: { name: "43.10.in-addr.arpa."; stub-addr: 10.43.0.10; }

Mount it (a subPath keeps the image’s own custom.conf.d files intact):

# unbound-deployment.yaml (excerpt)
        volumeMounts:
        - name: cluster-dns
          mountPath: /etc/unbound/custom.conf.d/cluster-dns.conf
          subPath: cluster-dns.conf
          readOnly: true
      volumes:
      - name: cluster-dns
        configMap: { name: unbound-config }

Then switch Rspamd’s internal upstreams to FQDNs (since its resolver won’t add the search domain): servers = "redis:6379"servers = "redis.mailstack.svc.cluster.local:6379", and the same for clamav. Restart the affected pods — Unbound rolls automatically (the Deployment changed), but Rspamd doesn’t (only its ConfigMap did): oc rollout restart deploy/rspamd.

Verify the thing that was actually broken — that Unbound now answers the internal name:

$ nslookup redis.mailstack.svc.cluster.local 10.43.0.53
Name:    redis.mailstack.svc.cluster.local
Address: 10.43.115.28            # was empty/NXDOMAIN before

…and that the RBL side still recurses (the external maps keep loading, no more address not resolved yet in the Rspamd log).

The general lesson: the moment a single recursive resolver becomes a pod’s only nameserver, it owns all of that pod’s DNS — including the cluster-internal names it knows nothing about. Either forward the internal zones back to CoreDNS (above) or don’t make it the sole resolver.


Takeaways

  • Your spam filter is only as good as its resolver. Point it at 8.8.8.8/1.1.1.1 and your RBLs are silently disabled. The tell: dig @8.8.8.8 2.0.0.127.zen.spamhaus.org returns 127.255.255.254.
  • A “recursive resolver” that forwards isn’t one. Use an image that recurses from the root and runs unprivileged.
  • Detection and policy are separate. Get the lookups working, then reject hard only on signals that unambiguously implicate the sender; tag everything softer.
  • Test on the real SMTP path with Spamhaus BLT, not just local scans.
  • A sole recursive resolver owns all of a pod’s DNS — internal names included. Forward the cluster zones (cluster.local + your reverse ranges) back to CoreDNS via a stub-zone, or your in-cluster Redis/ClamAV silently stop resolving.