Blocky: Lightweight DNS Ad Filtering Without the Database
Simple and lightweight ad blocking and DNS over HTTP server using blocky.
Introduction
Pi-hole is the default recommendation for DNS-level ad blocking. But Pi-hole carries real overhead — a web UI, a SQLite database for query logging, a DHCP server you probably don't need, and a dependency on dnsmasq. If you're running infrastructure where simplicity and resource footprint matter, there's a cleaner option: Blocky.
Blocky is a DNS proxy written in Go. Single binary. No database. No web UI. No persistent state beyond its in-memory cache. It reads blocklists at startup and on a configurable refresh schedule, resolves upstream via DNS over HTTPS (DoH), and gets out of the way. By the end of this guide, you'll have:
- A Blocky instance resolving DNS through privacy-respecting upstream resolvers
- DNS-level blocking using two of the best-maintained community blocklists
- A hardened systemd service that runs without full root privileges
- Automatic blocklist refresh every 24 hours
Prerequisites:
- A Linux server (this guide assumes a Debian/Ubuntu-based system)
sudoor root access- Basic familiarity with systemd and YAML configuration
- Network access from the clients you want to point at this resolver
Architecture Overview
Blocky sits between your clients and the internet. When a client asks for example.com, Blocky first checks whether that domain appears on any configured blocklist. If it does, Blocky returns 0.0.0.0 immediately — no upstream query made, nothing logged at the resolver. If it doesn't, Blocky forwards the query upstream via DoH.
This guide configures two upstream resolvers:
- Mullvad's adblock DoH endpoint (
adblock.doh.mullvad.net) — includes Mullvad's own ad and tracker blocking at the resolver level, layered on top of Blocky's local blocklists. - Quad9 (
dns.quad9.net) — blocks known-malicious domains and is operated as a nonprofit with a clear no-logging policy.
Blocky sends queries to both upstreams in parallel and uses the first valid response. The failOnError init strategy means Blocky refuses to start if neither upstream is reachable at launch — which is the right behavior for a service that's supposed to be providing DNS to your network. A silent partial failure is worse than a loud startup failure you can immediately diagnose.
Step 1: Download and Install Blocky
wget https://github.com/0xERR0R/blocky/releases/download/v0.29.0/blocky_v0.29.0_Linux_x86_64.tar.gz
tar -xzf blocky_v0.29.0_Linux_x86_64.tar.gz
sudo mv blocky /usr/local/bin/blocky
sudo chmod +x /usr/local/bin/blocky
sudo mkdir /etc/blockywget https://github.com/0xERR0R/blocky/releases/download/v0.29.0/blocky_v0.29.0_Linux_arm64.tar.gz
tar -xzf blocky_v0.29.0_Linux_arm64.tar.gz
sudo mv blocky /usr/local/bin/blocky
sudo systemctl restart blockyWhat this does: You're downloading the pre-compiled binary directly from Blocky's GitHub releases page, extracting it, and placing it in /usr/local/bin — the standard location for locally-installed system binaries. The chmod +x ensures the binary is executable. Finally, you're creating a dedicated directory for Blocky's configuration.
There's no package manager step here, no dependency resolution, no library version conflicts to sort through. That's one of the concrete advantages of a statically compiled Go binary: it carries everything it needs. Deployment is a file copy.
Check the Blocky releases page before running this and substitute the current version if v0.29.0 isn't the latest.
Step 2: Configure Blocky
Create the configuration file:
sudo nano /etc/blocky/config.ymlPaste the following:
upstreams:
init:
strategy: failOnError
groups:
default:
- https://adblock.doh.mullvad.net/dns-query
- https://dns.quad9.net/dns-query
timeout: 2s
startVerifyUpstream: true
blocking:
denylists:
base:
- https://raw.githubusercontent.com/hagezi/dns-blocklists/refs/heads/main/hosts/pro.txt
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
clientGroupsBlock:
default:
- base
blockType: zeroIp
blockTTL: 1m
loading:
refreshPeriod: 24h
caching:
minTime: 5m
maxTime: 30m
maxItemsCount: 0
prefetching: true
ports:
dns: 10.1.10.1:53,[fd:10:1:10::1]:53
log:
level: warn
format: textWhat each setting does:
upstreams.init.strategy: failOnError — Blocky will refuse to start if it can't reach any configured upstream resolver. This is the right default for a DNS server: a silent partial failure is worse than a loud startup failure you can see in journalctl immediately.
upstreams.groups.default — The list of upstream DoH resolvers Blocky forwards uncached, non-blocked queries to. Blocky sends to both in parallel and uses the first valid response. Mullvad's adblock endpoint does additional filtering at the resolver level on top of whatever Blocky blocks locally. Quad9 focuses on blocking known-malicious domains and operates with a no-logging posture.
upstreams.timeout: 2s — If an upstream doesn't respond within 2 seconds, move on. This keeps latency predictable even when one resolver is having a slow moment.
startVerifyUpstream: true — Blocky sends an actual test query to each upstream at startup to confirm they're reachable and returning valid responses. This is a functional check, distinct from the init strategy's connectivity check.
blocking.denylists.base — Two of the most widely used and actively maintained DNS blocklists in the community. Hagezi's Pro list is aggressive but carefully curated; StevenBlack's hosts is a long-running project that aggregates multiple sources. Using both gives solid coverage. There's overlap, but Blocky deduplicates entries at load time.
blocking.clientGroupsBlock.default — Every client gets the base blocklist by default. Blocky supports per-client or per-CIDR group rules, so you can carve out exceptions for specific IP ranges if you need to — a media server that legitimately needs to reach ad-serving CDNs, for example. For most setups, one policy for everyone is the right starting point.
blocking.blockType: zeroIp — Blocked domains return 0.0.0.0 (and :: for IPv6) rather than an NXDOMAIN or a redirect to a block page. Zero IP is the standard approach and plays well with clients that aggressively cache NXDOMAIN responses.
blocking.blockTTL: 1m — How long clients cache a "blocked" response. One minute is conservative — long enough to reduce repeated block queries, short enough that removing a domain from your blocklist takes effect quickly.
blocking.loading.refreshPeriod: 24h — Blocky re-downloads the blocklists every 24 hours. The maintainers update their lists frequently; this keeps you current without hammering their servers.
caching.minTime: 5m / caching.maxTime: 30m — Blocky respects upstream TTL values, but clamps them to this range. The minimum prevents hammering upstream resolvers for domains with very aggressive TTLs. The maximum prevents stale entries from sitting in cache too long.
caching.maxItemsCount: 0 — No limit on the number of entries held in memory. For home or small office use this isn't a concern. On a memory-constrained system, set an explicit limit.
caching.prefetching: true — Before a cached entry expires, Blocky proactively re-resolves it if it's been queried frequently. This eliminates the latency spike you'd otherwise see when a popular entry expires and has to be resolved fresh.
ports.dns: 10.1.10.1:53,[fd:10:1:10::1]:53 — Blocky listens on these specific IP addresses, not on all interfaces. Binding to a specific IP rather than 0.0.0.0 is intentional: this instance serves a specific network segment (the 10.1.60.x subnet and its IPv6 equivalent), not every interface on the host. Adjust these to match your actual network addressing. If you want Blocky to listen on all interfaces, use dns: 53.
log.level: warn — Only log warnings and errors. At the info level, Blocky logs every single DNS query — which is a lot of noise on any active network and grows log files quickly. warn keeps the signal-to-noise ratio high. If you want query logging for analysis or debugging, set it to info temporarily and use journalctl -u blocky -f to watch the stream.
Step 3: Create the systemd Service
sudo nano /etc/systemd/system/blocky.servicePaste the following:
[Unit]
Description=Blocky DNS proxy
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/blocky --config /etc/blocky/config.yml
Restart=on-failure
RestartSec=5s
# Harden
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/etc/blocky
# Allow binding to port 53
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.targetWhat this does:
After=network-online.target and Wants=network-online.target — Blocky needs the network fully up before it starts, because it immediately attempts to download blocklists and verify upstream resolvers. Without this ordering, Blocky would start before your network stack finished initializing, fail to reach the internet, and exit.
Restart=on-failure / RestartSec=5s — If Blocky crashes or exits with a non-zero status, systemd restarts it after 5 seconds. DNS is infrastructure. A crash shouldn't mean your whole network stops resolving names.
NoNewPrivileges=true — The Blocky process cannot gain additional Linux capabilities after launch. Standard hardening that prevents privilege escalation if the process were somehow compromised.
ProtectSystem=strict / ProtectHome=true — Blocky gets a read-only view of the filesystem, with home directories completely inaccessible. It can only write to paths explicitly listed in ReadWritePaths.
ReadWritePaths=/etc/blocky — The exception to the strict filesystem policy. Blocky needs to cache downloaded blocklist files, so it gets write access to its configuration directory specifically.
AmbientCapabilities=CAP_NET_BIND_SERVICE / CapabilityBoundingSet=CAP_NET_BIND_SERVICE — Port 53 is a privileged port (below 1024), which traditionally requires root. CAP_NET_BIND_SERVICE grants the specific capability to bind low-numbered ports without running the entire process as root. The service file as written doesn't specify a User= directive, so Blocky runs as root. If you want to harden further, create a dedicated system user — sudo useradd -r -s /usr/sbin/nologin blocky — and add User=blocky to the [Service] block.
Step 4: Enable and Start Blocky
sudo systemctl daemon-reload
sudo systemctl enable --now blocky
sudo systemctl status blockyWhat this does: daemon-reload tells systemd to read the new service file. enable --now both marks the service to start on boot and starts it immediately. The status command gives you an immediate view of whether it came up cleanly.
A healthy output looks like:
● blocky.service - Blocky DNS proxy
Loaded: loaded (/etc/systemd/system/blocky.service; enabled)
Active: active (running) since ...If Blocky exits immediately after starting, the most common causes are: upstream resolvers unreachable at boot time (check your network and any firewall rules governing outbound DoH traffic on port 443), or a YAML syntax error in the config (Blocky will print the offending line to the journal). Check with journalctl -u blocky -n 50.
To verify it's actually resolving queries, point a DNS lookup at it directly:
dig @10.1.10.1 north.engineerAnd test that blocking is working:
dig @10.1.10.1 doubleclick.netA blocked domain should return 0.0.0.0 with a short TTL.
Closing Thoughts
Pi-hole is a fine tool. If you want a web dashboard, per-client query logging, and a DHCP server all in one package, Pi-hole makes sense. But software accumulates surface area. A SQLite database is a database you have to maintain and occasionally repair. A web UI is a web server you have to keep patched. A DHCP server is another thing to configure and debug when you didn't need it in the first place.
Blocky's approach is the opposite: a single binary, a YAML file, and a systemd unit. It does DNS filtering and DNS caching. That's it. The configuration is explicit rather than GUI-driven, which means it's version-controllable, reproducible across machines, and legible to anyone who reads YAML. If your Blocky instance dies and you need to rebuild it on a new host, the entire state of the system is in one file you can commit to git.
The constraint is the feature.