572,000 domains and the router barely notices

RouterOS 7.15 added native ad-blocking. /ip/dns/adlist, point it at StevenBlack, bump the DNS cache above 2MB, done. No container, no USB stick, no extra hardware. If all you want is "ads go away," native adlist handles it and has since May 2024.

But native adlist can't encrypt upstream queries. Can't group clients into different blocking profiles. Can't expose Prometheus metrics. Can't log queries to CSV. And — the interesting part — can't trick clients out of using their own encrypted DNS resolvers.

blocky does all of that. One binary, one YAML file, running in a RouterOS container on a USB SSD. Here's the whole setup on a hAP ax3, start to finish.

Why blocky

blocky is a Go DNS proxy that does ad-blocking the same way Pi-hole does — intercept queries, match against denylists, return NXDOMAIN. The difference: no PHP, no web UI, no database. Single binary, config-file-only, Pi-hole-compatible blocklists.

The other candidates:

  • Pi-hole requires a separate Linux box. That's the whole point of avoiding it.
  • AdGuard Home is also Go, but ships a full React web GUI, a DHCP server, and more surface area than a router container needs.
  • Native /ip/dns/adlist is simplest, but it's DNS-only sinkholing with no DoH upstream, no metrics, no query log, and no bypass resistance.

The port dance

blocky's Docker image runs as USER 100. Not root. One line in the Dockerfile, but it cascades into the entire network design: the binary can't bind to port 53, or any port below 1024.

The config listens on 6969 instead:

ports:
  dns: 6969
  http: 4000

The router makes this invisible. A dst-NAT rule catches every DNS query from the LAN on port 53 and redirects it to the container:

/ip firewall nat add chain=dstnat \
  protocol=udp dst-port=53 src-address=192.168.88.0/24 \
  action=dst-nat to-addresses=172.17.0.2 to-ports=6969 \
  comment="blocky-dns-redirect"

/ip firewall nat add chain=dstnat \
  protocol=tcp dst-port=53 src-address=192.168.88.0/24 \
  action=dst-nat to-addresses=172.17.0.2 to-ports=6969 \
  comment="blocky-dns-redirect"

Clients keep pointing at the router (192.168.88.1) as their DNS server — DHCP already hands out that address. Nothing changes on any device. Every phone, laptop, and smart bulb on the network quietly starts getting ad-blocked DNS.

This also catches hardcoded resolvers. A device sending queries to 8.8.8.8:53 has them intercepted by the same rule before they leave the LAN.

The container network

The container lives on its own bridge, same concept as Docker's default bridge:

/interface bridge add name=bridge-containers
/ip address add address=172.17.0.1/24 interface=bridge-containers
/interface veth add name=veth-blocky address=172.17.0.2/24 gateway=172.17.0.1
/interface bridge port add bridge=bridge-containers interface=veth-blocky
/ip firewall nat add chain=srcnat src-address=172.17.0.0/24 \
  action=masquerade comment="container-nat"

The veth gives the container a routable address (172.17.0.2). The masquerade lets it reach the internet for blocklist downloads and upstream DNS.

Trick, not block

The usual advice for stopping DoH bypass: drop all outbound TCP 443 to known DoH providers. This breaks things. Cloudflare and Google serve a lot more than DNS on port 443.

The DNS-layer approach is cleaner: make DoH endpoints unresolvable.

blocking:
  denylists:
    ads:
      - https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
      - https://big.oisd.nl
    doh-block:
      - https://raw.githubusercontent.com/dibdot/DoH-IP-blocklists/master/doh-domains_overall.txt
      - |
        use-application-dns.net
  clientGroupsBlock:
    default:
      - ads
      - doh-block
  blockType: nxDomain

The doh-block group loads a list of DoH provider hostnames and adds Firefox's canary domain — 1,913 entries in total. blockType: nxDomain returns NXDOMAIN for every match.

Here's what happens when a browser tries to use its own encrypted DNS:

  1. The browser needs to resolve cloudflare-dns.com or dns.google to connect to its DoH server.
  2. That resolution goes through port 53. blocky intercepts it. NXDOMAIN.
  3. The DoH endpoint doesn't exist as far as this client knows. The browser falls back to plain DNS — also on port 53, also intercepted by blocky.
  4. Firefox adds a specific check: it queries use-application-dns.net before auto-enabling DoH. NXDOMAIN — Firefox disables its own encrypted DNS.

No firewall drops. No broken HTTPS. The clients defeat themselves.

One caveat: this stops auto-enabled DoH and any app that resolves DoH hostnames via system DNS. A user who manually configured DoH with a hardcoded IP address bypasses it. For that, you still need firewall rules. But for browsers and apps auto-discovering DoH — which is most of them — the trick works.

Encrypted upstream

blocky queries its own upstream over DoH. The ISP sees HTTPS traffic to Cloudflare, not DNS queries:

upstreams:
  groups:
    default:
      - https://cloudflare-dns.com/dns-query
      - https://dns.quad9.net/dns-query
  strategy: parallel_best
  timeout: 3s

bootstrapDns:
  - tcp+udp:192.0.2.1   # your ISP's plain resolvers (bootstrap only)
  - tcp+udp:192.0.2.2
  - upstream: https://cloudflare-dns.com/dns-query
    ips:
      - 1.1.1.1
      - 1.0.0.1

bootstrapDns handles the chicken-and-egg: resolving the DoH hostnames themselves requires DNS, so the ISP resolvers and Cloudflare-by-IP do the initial lookup.

Why this beats native adlist. RouterOS's built-in resolver forwards cache-misses to your ISP as plaintext port-53 DNS — the ISP sees every domain. blocky forwards them over DoH instead: the query rides inside a TLS request, so on-path observers see only HTTPS to :443, never the lookups. blocky also speaks DoT, DoQ and DoH3. Config reference: blocky upstreams docs.

DoH (DNS-over-HTTPS) wraps each DNS query in an ordinary HTTPS request, defined in RFC 8484. Because the traffic is indistinguishable from any other :443 connection, a network observer can't read or selectively drop individual lookups the way they can with cleartext DNS.

The numbers

Two blocklists loaded:

  • StevenBlack unified hosts: 78,450 domains
  • OISD big: ~525,000 domains

The raw total is ~603,000, but the lists overlap. After blocky deduplicates, the effective count is 572,813 unique domains blocked — from blocky's own denylist_cache_entries metric. blocky holds all of them in 54MB of resident memory and averages under 0.1% CPU (122 CPU-seconds across a day and a half of uptime); its DoH upstream resolves in ~21ms. The hAP ax3 — quad-core ARM64, 1GB RAM — doesn't notice the container at all.

Per-query CSV logs go to the SSD with 30-day retention. Prometheus metrics are exposed on :4000/metrics, ready for Grafana on a separate box.

The safety net

One command disables the redirect. The router's own DNS resumes instantly:

./rollback-adblock.sh dns-only

One command removes everything — container, veth, bridge, NAT rules:

./rollback-adblock.sh full

No reboot. The router's built-in DNS (allow-remote-requests=yes, upstream from the ISP) takes over. The SSD files stay intact.

The full rollback script is idempotent and forces -o ControlMaster=no on every SSH invocation.

The healthcheck says UNHEALTHY

The container shows a U flag. UNHEALTHY.

blocky's image healthcheck probes port 53. We serve on 6969. The fix is four docs deep: RouterOS has a healthcheck-cmd property, blocky's healthcheck subcommand accepts -p <port>, and the image is distroless (no /bin/sh) so the command must use the exec/array form:

/container/set [find name=blocky] healthcheck-cmd="CMD,/app/blocky,healthcheck,-p,6969"

DNS works. Ads are blocked. 572,813 domains, 1% CPU. The healthcheck agrees now too — once you find the -p flag buried in blocky's source, and the CMD, exec form buried in an unrelated MikroTik example.

Sources