~/notes/2026|09|05
dnscrypt-proxy: Automating the Firewall with nftables Sets
Blocking a domain by hand works exactly once. The second time you add a rule, you start wondering whether there is a better way — and there is. An nftables set can be updated while the firewall is running, which means a blocklist becomes a live rule instead of a pile of manual commands.
The pattern: a set, and something to fill it
Instead of one rule per domain, you create a named set and point a single rule at it. Then you only ever add or remove elements:
nft add set inet filter blocklist { type ipv4_addr; flags interval; }
nft add rule inet filter forward ip daddr @blocklist drop
nft add rule inet filter input ip daddr @blocklist dropNow anything you add to the set is blocked by the same rule. The trick is feeding the set automatically, because a static list is just manual work with extra steps.
A script that feeds the firewall
The clean way is a small script that resolves a domain list and pushes the addresses into the set:
for DOMAIN in $(cat /etc/blocklist.domains); do
getent ahostsv4 "$DOMAIN" | awk '{print $1}' | sort -u
doneFeed that output to nftables and you have a live blocklist. Run it once by hand, then on a schedule. The full script, with the set refresh, looks like this:
#!/usr/bin/env bash
# /usr/local/sbin/update-blocklist.sh
set -euo pipefail
nft flush set inet filter blocklist
for DOMAIN in $(cat /etc/blocklist.domains); do
getent ahostsv4 "$DOMAIN" | awk '{print $1}' | sort -u
done | while read -r IP; do
nft add element inet filter blocklist { "$IP" }
doneThen hook it to a systemd timer: a .service unit that runs the script plus a .timer unit on OnCalendar=hourly, enabled with systemctl enable --now update-blocklist.timer. The flush before the reload keeps the set in sync when a domain's IPs change between runs.
The flush first, then the reload, keeps the set in sync when a domain's IPs change between runs. Run it once by hand, then from a systemd timer every hour — the timer unit is three lines and does not need showing here.
Domains that resolve to several IPs (hello, CDNs) get every address blocked, and the block stays correct as long as the DNS does not change — which is why the hourly refresh matters. When an address changes, a fresh run replaces the set:
# clear and reload, safely, from the same script:
nft flush set inet filter blocklistnftables makes the hard part — editing live firewall state without dropping your SSH session — a two-command operation. The set pattern is the same one behind fail2ban-style tools: the firewall stays simple, and the logic lives in a script you can read.
One honest caveat: DNS-based blocking is a convenience filter, not a security boundary. Anything that bypasses your DNS (a hardcoded IP, a VPN, DoH) walks around it. Use it to keep noise out of your network, and keep your real security in the places that do not depend on name resolution.