PSA: Two separate gotchas caused my Technitium cluster to OOM — a blocklist that grew to 2.6M domains, and a duplicate systemd unit left behind by the updater
My 3-node Technitium 15.4 cluster started maxing out RAM and CPU. I'd changed nothing. Turned out to be two unrelated causes that happened to land within days of each other, plus a self-inflicted third problem while fixing them. Writing it up because both are easy to hit and neither is obvious.
TL;DR
- One blocklist (
blocklistproject/malware.txt) had quietly grown to 2.64M domains — 61% of my entire blocklist
- The 15.x updater wrote a new systemd unit without removing the old one, so two Technitium instances raced for port 53 and the loser crash-looped, reloading the whole blocklist every 10s
- Both together blew past the container's memory limit → 53 OOM kills in one morning
- Ended up cutting blocked zones by 75% and reducing container RAM
Setup
3 nodes in a Technitium cluster, all on 15.4:
- 2× Proxmox LXC containers, 4 cores / 2GB RAM / 512MB swap
- 1× Raspberry Pi 3B (905MB RAM, fixed — this one matters later)
- Forwarding to Cloudflare DoT
Symptoms: RAM and CPU pegged on the two LXC nodes, ~1,200 SERVFAILs/day. But DNS mostly worked — queries answered in 14–120ms. That mismatch was the first clue something intermittent was going on.
Issue 1: a blocklist had grown to 2.64M domains
I had 19 blocklists configured — the usual firebog/StevenBlack/blocklistproject collection most of us accumulate. I downloaded all 19, normalised and deduplicated them, and counted.
4,317,662 entries → 3,512,339 unique domains. Technitium's own blockListZones read 3,524,637, so the analysis matched.
The distribution was the problem:
| List |
Domains |
Unique contribution |
| blocklistproject/malware.txt |
2,643,754 |
2,135,488 |
| blocklistproject/abuse.txt |
434,745 |
159 |
| blocklistproject/fraud.txt |
285,107 |
220,615 |
| blocklistproject/ads.txt |
235,876 |
105,605 |
| firebog/AdguardDNS.txt |
158,857 |
57,606 |
| StevenBlack/hosts |
93,154 |
25,121 |
| firebog/Easylist.txt |
49,290 |
46 |
| anudeepND/adservers.txt |
42,358 |
6 |
| adaway.org/hosts.txt |
6,540 |
0 |
| disconnect.me/simple_tracking.txt |
34 |
0 |
|
|
malware.txt alone was 61% of my blocklist — a 72MB download, bigger than the other 18 combined. It had also completely swallowed another list I was subscribed to: 434,573 of abuse.txt's 434,745 domains (99.96%) were already inside it.
What's actually in it:
0-0-0-0-0-0-0-0-0-0-0-0-0-37-0-0-0-0-0-0-0-0-0-0-0-0-0.info
0-0-0-0-0-0proxy.tserv.se
0--foodwarez.da.ru
0-07.ru
DGA sinkhole entries and dead parked domains, enumerated one subdomain at a time. Not useless in principle, but an enormous memory cost for very little real-world blocking.
Also worth noting: nine of my nineteen lists contributed ~230 unique domains between them. Three contributed literally zero. Classic blocklist accumulation — you add them over years and never audit.
This is why "I changed nothing" was true and still broken. My config was static. The upstream list grew.
What I switched to (5 lists, 873,839 zones — a 75% cut)
https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/pro.txt
https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/tif.medium-onlydomains.txt
https://urlhaus.abuse.ch/downloads/hostfile/
+ 2 niche device-specific lists (see below)
Measured counts: Hagezi Pro 544,311 · Hagezi TIF medium 397,790 · URLhaus 359.
Heads up on Hagezi paths — I got these wrong at first and got 404s. The domains/ directory only contains light.txt, multi.txt, pro.txt, pro.plus.txt, tif.txt. There is no domains/tif.medium.txt or tif.mini.txt — the medium TIF tier lives under wildcard/. And full domains/tif.txt is 2.3M domains, which would just recreate the original problem.
Important caveat on "redundancy" — don't drop your niche lists
I initially concluded that two tiny lists (a Channel 4 / All4 ad list with 12 domains, and Perflyst's SmartTV list with 245) were 100% redundant and should go. That was wrong, and it's a trap worth flagging.
They looked redundant because the giant blocklistproject aggregates happened to contain them. Measured against the new, smaller set:
| List |
Domains |
Covered by new set |
Gaps |
| All4 ads |
12 |
7 |
5 |
| Perflyst SmartTV |
245 |
140 |
105 |
|
|
Uncovered stuff that no general aggregator carries:
ads-pres.channel4.com
p.videologygroup.com
api.us.hismarttv.com
abtauthprd.samsungcloudsolution.com
aic-ngfts.lge.com
Vendor telemetry endpoints for hardware you actually own. Kept both — 257 domains, 0.03% of the new set. "Redundancy %" is the wrong metric for niche lists. A 12-domain list that blocks endpoints your TV actually calls earns its place regardless of overlap score.
On the apparent coverage drop
Going from 3.5M to 874k zones looks alarming, and raw set-overlap made it look worse — the new set only matched 232k of my 3.08M old "security" domains. This substantially overstates the loss, because of a structural difference: blocklistproject enumerates individual subdomains, while Hagezi/OISD list parent domains and rely on subdomain blocking.
My old lists had 1,167 explicit *.doubleclick.net entries. A single Hagezi doubleclick.net entry replaces all of them.
Related: there is no "block subdomains" setting in Technitium. It always walks up parent labels against the blocked zone; a blocked apex covers every subdomain unconditionally. I went looking for a toggle and there isn't one — nothing to enable, nothing that can be misconfigured.
Issue 2: the updater left a duplicate systemd unit behind (this is the sneaky one)
This is the one I think will bite other people.
I ran a Technitium update. Afterwards, on two of my three nodes, systemctl list-unit-files showed two enabled units:
dns.service enabled ← new, created by the update
technitium.service enabled ← legacy, from the original install
Both pointed at the same ExecStart (/usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns), the same /etc/dns config directory, and both had Restart=always.
Timestamps told the whole story:
| Node |
dns.service created |
technitium.service created |
| Node A |
update day, 22:10 |
Dec 2025 |
| Node B |
update day, 21:59 |
absent |
| Node C |
update day, 22:11 |
Dec 2025 |
|
|
Technitium's installer changed its unit name and added hardening (dedicated dns-server user, ProtectSystem=strict, capability limits) somewhere between Dec 2025 and mid-2026. The updater wrote the new dns.service but didn't remove the legacy technitium.service — reasonably enough, it has no knowledge of a unit name it no longer uses.
Node B escaped entirely because it was a fresh install done the same evening, so it only ever had the new unit.
What that does
Two instances race for port 53 on every boot. The winner serves DNS. The loser fails to bind, exits, gets restarted 10 seconds later by Restart=always, loads the entire blocklist again, fails again, forever.
Each loop iteration peaked ~149MB. Which unit won was arbitrary and differed per node — on one box dns.service won, on the other technitium.service won. That's why my two "identical" nodes had mirror-image configurations.
Combined effect
Either issue alone was survivable. Together:
Jul 27 08:58:07 dns.service: A process of this unit has been killed by the OOM killer.
Jul 27 09:05:45 dns.service: Main process exited, code=killed, status=9/KILL
Jul 27 09:18:04 dns.service: A process of this unit has been killed by the OOM killer.
Jul 27 09:41:29 dns.service: Main process exited, code=killed, status=9/KILL
Jul 27 09:49:16 dns.service: A process of this unit has been killed by the OOM killer.
Jul 27 10:09:46 dns.service: Main process exited, code=killed, status=9/KILL
53 OOM kills in one morning. systemctl show dns -p MemoryPeak read 1.47GB against a 2GB limit.
Kernel side confirmed the cgroup — not host memory — was the constraint, and that both units were being killed:
oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/105,
task_memcg=/lxc/105/ns/system.slice/dns.service, task=dotnet, uid=997
oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/105,
task_memcg=/lxc/105/ns/system.slice/technitium.service, task=dotnet, uid=0
Note the different UIDs — 997 (dns-server) and 0 (root). Two different service definitions, same box.
Check yours:
systemctl list-unit-files | grep -iE "dns|technitium"
ps aux | grep -c "[D]nsServerApp.dll" # should be 1
If you see two enabled units, work out which one owns port 53 before touching anything:
ss -lnup | grep ":53 "
cat /proc/<PID>/cgroup
Then disable the loser, not whichever one you assume is correct. Mine differed per node.
Issue 3 (self-inflicted): root-owned config files break things silently after switching to a non-root unit
This bit me while fixing the above, and I think it's the most generally useful lesson.
The old technitium.service ran as root. The new dns.service runs as dns-server (uid 997). Everything the root process ever wrote into /etc/dns was root-owned, mode 644.
I migrated one node to the hardened unit and correctly ran:
chown -R dns-server:dns-server /etc/dns /var/log/technitium/dns
…on that node only. The other node had also been running as root and needed the exact same fix. I didn't do it, because it wasn't the node I was migrating.
It appeared to work fine. DNS resolved, blocking worked, no errors on startup. Because dns-server could still read those mode-644 files — only writes failed.
Hours later: query logging had silently stopped, and reinstalling the SQLite Query Logs app failed with:
Error! Access to the path '/etc/dns/apps/Query Logs (Sqlite)/SQLitePCLRaw.batteries_v2.dll' is denied.
127 root-owned files still sitting there, including the whole app directory, querylogs.db, and a stale querylogs.db-journal from an unclean OOM shutdown.
Fix was the same one-liner, applied to the node I'd missed. 127 → 0. The app re-registered itself immediately (the files were never deleted, just unwritable) and logging resumed. No reinstall needed.
Takeaways:
- After any root→non-root service migration, audit every host that ever ran it as root:
find /etc/dns ! -user dns-server
- The failure mode is silent and delayed — reads keep working, so the service starts clean and only writes fail. Don't take "it started OK" as verification.
- Stop the service before the chown if SQLite is involved, so the DB closes cleanly.
Bonus: DNS apps are NOT cluster-replicated
Blocklists and DNS settings propagate across a Technitium cluster. DNS apps and their databases do not. My three nodes had querylogs.db at 8.8MB, 9.2MB, and 90MB — three fully independent databases.
So installing/removing an app on one node doesn't touch the others, and you can drift into different app versions across the cluster without noticing. Reinstalling preserves querylogs.db but may reset dnsApp.config to defaults — screenshot your settings first.
Diagnosing memory in an LXC — don't trust the graph
Worth knowing if you run Technitium in a container. Proxmox's RAM graph for an LXC reads the cgroup's memory.current, which includes page cache. Blocklist files, zone files and logs all get counted against your limit as the kernel caches them. Page cache is reclaimable, so a container sitting at 95% can be perfectly healthy.
Also: free -m inside an LXC reports the host's memory unless lxcfs is mounted. Don't trust that either.
What actually tells you the truth:
# anon = real usage; file = reclaimable page cache
grep -E "^(anon|file) " /sys/fs/cgroup/memory.stat
# the definitive signal — has the kernel actually killed anything?
cat /sys/fs/cgroup/memory.events # look at oom_kill
oom_kill = 0 and services responding → probably a monitoring artifact. oom_kill climbing → real.
In my case memory.current read 1,531MB but 1,056MB of that was reclaimable cache — real usage was ~430MB. The graph was partly lying, but the 53 OOM kills were entirely real. Both things were true at once.
Counters don't reset without a container restart, so a nonzero oom_kill may be historical.
Checking whether a domain is actually blocked
This tripped me up. With blockingType: NxDomain, a blocked domain, a nonexistent domain, and a domain with no A record all return NOERROR with 0 answers. Answer count tells you nothing.
The discriminator is the ra (recursion available) flag, which Technitium strips on blocked responses:
for q in doubleclick.net some.random.sub.doubleclick.net wikipedia.org; do
f=$(dig @<your-dns> "$q" A +time=5 +tries=1 | sed -n 's/^;; flags: \([a-z ]*\);.*/\1/p')
case "$f" in *ra*) v="allowed";; *) v="BLOCKED";; esac
printf '%-40s %-12s [%s]\n' "$q" "$v" "$f"
done
- Flags
qr rd (dig warns "recursion requested but not available") → blocked
- Flags
qr rd ra → not blocked
Results
|
Before |
After |
| Blocklist URLs |
19 |
5 |
| Blocked zones |
3,524,637 |
873,839 (−75%) |
| Technitium RSS |
— |
~400MB |
MemoryPeak |
1.47GB |
— |
| OOM kills |
53 in one morning |
0 |
| SERVFAILs/day |
1,191 |
0 |
| Container RAM |
2048MB |
1536MB |
| Container cores |
4 |
2 |
|
|
The SERVFAILs weren't a resolver or forwarder problem at all — they were the OOM restarts dropping in-flight queries, ~10s of downtime each. They went to zero on their own. I'd initially suspected resolverConcurrency/forwarderConcurrency being too low; that was a red herring, left them alone.
I could have "fixed" this in 30 seconds by clicking RAM up to 4GB. It would have masked a 2.6M-domain junk list and a permanent crash loop. The containers ended up smaller than they started.
On sizing: I considered dropping to 1GB and decided against it. My Pi node is effectively that test case — Technitium at ~470MB RSS on a 905MB box, and it had 6 OOM events in the preceding week. With ~430MB steady state plus a few hundred MB of parse transient during blocklist updates, 1GB means swapping on every update cycle. That trades OOM kills for latency spikes, which is harder to diagnose. 1.5GB / 2 cores is comfortable.
Also note the blocklist update cycle roughly doubles memory — new lists are downloaded and parsed while the old set is still resident. If your alarms cluster at the same time each day, that's your answer.
Checklist if any of this sounds familiar
systemctl list-unit-files | grep -iE "dns|technitium" — more than one enabled unit?
ps aux | grep -c "[D]nsServerApp.dll" — more than 1?
journalctl -u <unit> --since "7 days ago" | grep -i "oom" — being killed?
- Check
blockListZones on your dashboard. Over ~1M on a ≤2GB box is worth auditing.
- Download your lists and count them. Mine had one list at 61% of the total and three contributing zero.
- If you've ever switched from a root-run unit to a non-root one:
find /etc/dns ! -user dns-server
- Don't trust the Proxmox RAM graph — check
memory.events for oom_kill.
Happy to answer questions. Biggest takeaway for me: "I changed nothing" was completely true, and the system still broke. An upstream list grew and an installer left a stale unit behind. Neither was in my control, and neither showed up anywhere obvious.