U2 / Networking and remote access
Pi-hole vs AdGuard Home on one 300,000-domain list
Tested on Pi-hole 2026.10.1
Test result
Both blocked the test list correctly, answering 0.0.0.0 and ::, and both loaded 300,000 domains in under 3 s. Throughput was close and the three-run ranges overlap (Pi-hole 31,030 allowed and 24,888 blocked queries/s, AdGuard Home 29,292 and 29,161), but AdGuard Home idled at 57.8 MiB against Pi-hole's 10.0 MiB, and a Pi-hole container with Docker's 64 MB /dev/shm and its rate limit switched off exited mid-benchmark when that memory filled.

How the test was set up
Each resolver ran alone in a throwaway VM with the same upstream: a CoreDNS container that answers every A query with 10.99.0.1, so internet latency never enters the numbers. Both loaded the same blocklist, then took the same dnspyre workloads from 16 concurrent clients. The list is generated, not a real-world one: 300,000 made-up hosts-format entries (0.0.0.0 ads123.edge45.com), produced by a seeded script, so it has no license and no relation to any real tracker. Names sit under com, net and org and never leave the VM, because the upstream is the local CoreDNS.
Two defaults had to be switched off, or the benchmark would have measured the rate limiter. Pi-hole’s FTL config defaults to 1000 queries per 60 seconds per client; AdGuard Home’s configuration page gives a default of 20 queries per second (that wiki page carries its own warning that it is outdated, so the figure may have changed). Both were set to 0 here. Anyone copying these numbers to a real network keeps the defaults, which also means that the Pi-hole crash described below happened with the rate limit off. Pi-hole was also set to listen on all interfaces (FTLCONF_dns_listeningMode: ALL, as in its Docker docs example). Each workload ran 3 timed runs of 15 s after a 5 s warm-up, one dnspyre pass per run.
Loading the list
| Resolver | Time to load the list | Domains or rules reported as loaded | RAM with the list loaded, idle, MiB (median of 12) | Container start to web server answering |
|---|---|---|---|---|
| Pi-hole 2026.10.1, Docker default /dev/shm (64 MB) | 2.22 s for the gravity API call (download + build) | 300,000 | 10.2 | 5.55 s |
| Pi-hole 2026.10.1, shm_size 512 MB | 1.82 s for the gravity API call (download + build) | 300,000 | 10.0 | 4.39 s |
| AdGuard Home 0.107.79 | 1.16 s for the add_url API call (download + parse), reply: OK 300000 rules | 300,000 | 57.8 | 0.87 s |
The two timings are not the same operation: Pi-hole rebuilds its gravity database, AdGuard Home downloads and parses the list in memory, so compare them as 'time until the list is active', not as a like-for-like parse speed. List: hosts format, generated by gen-lists.py, SHA-256 in raw/list-sha256.txt. VM 4 vCPU, 3 GB.
The timings are not the same operation, as the table notes say: Pi-hole rebuilds its gravity database on disk, AdGuard Home downloads and parses the list into memory. That difference shows in the RAM column. With the list loaded and nothing happening, Pi-hole sat near 10 MiB and AdGuard Home at 57.8 MiB. For context, the project’s idle measurement of each without any list gave 9.5 MiB for Pi-hole and 11.3 MiB for AdGuard Home:
| Stack | Version | RAM at idle | CPU at idle | Images on disk | Ready after |
|---|---|---|---|---|---|
| Pi-hole | 2026.10.1 | 0.21% | 148.8 MB | 5.2 s | |
| AdGuard Home | 0.107.79 | 0.00% | 113.6 MB | 1.2 s |
Does a blocked name come back blocked
| Resolver | Blocked name, A query | Blocked name, AAAA query (+short) | Allowed name, A query (upstream answers 10.99.0.1) |
|---|---|---|---|
| Pi-hole 2026.10.1, Docker default /dev/shm (64 MB) | NOERROR, 0.0.0.0 | :: | NOERROR, 10.99.0.1 |
| Pi-hole 2026.10.1, shm_size 512 MB | NOERROR, 0.0.0.0 | :: | NOERROR, 10.99.0.1 |
| AdGuard Home 0.107.79 | NOERROR, 0.0.0.0 | :: | NOERROR, 10.99.0.1 |
The blocked name is the first entry of blocked-queries.txt, taken from the loaded list. Pi-hole documents NULL as its default blocking mode (0.0.0.0 and ::).
Yes on both: A queries for a name from the list returned 0.0.0.0 and AAAA queries returned ::, while a name outside the list got the upstream’s 10.99.0.1. Pi-hole documents NULL mode as its default, which matches. The AdGuard Home configuration page contradicts itself about which blocking mode is the default, so the result here is the observed behavior on 0.107.79, not a documented guarantee.
Throughput and latency
| Resolver | Workload | Queries/s, median (range) | p50, µs, median (range) | p99, µs, median (range) | Read/write errors over 3 runs | RAM during load, MiB (median, max) |
|---|---|---|---|---|---|---|
| Pi-hole 2026.10.1, Docker default /dev/shm (64 MB) | allowed | 27,740 (3,010 to 28,179) | 524 (459 to 557) | 1,640 (1,310 to 1,640) | 75 of 883,960 | 147.8 (max 253.3) |
| Pi-hole 2026.10.1, Docker default /dev/shm (64 MB) | blocked | per run: 3, 3, 3 (resolver stopped answering mid-test) | n/a | n/a | 144 of 144 | container down |
| Pi-hole 2026.10.1, shm_size 512 MB | allowed | 31,030 (26,741 to 31,440) | 508 (492 to 557) | 1,570 (1,250 to 2,230) | 25 of 1,338,202 | 237.2 (max 382.8) |
| Pi-hole 2026.10.1, shm_size 512 MB | blocked | 24,888 (24,472 to 28,296) | 557 (524 to 590) | 2,230 (1,700 to 2,360) | 25 of 1,164,875 | 581.7 (max 725.7) |
| AdGuard Home 0.107.79 | allowed | 29,292 (25,416 to 31,376) | 426 (393 to 426) | 3,280 (3,150 to 4,060) | 33 of 1,291,280 | 125.1 (max 129.7) |
| AdGuard Home 0.107.79 | blocked | 29,161 (25,931 to 33,512) | 426 (360 to 459) | 3,150 (3,010 to 3,540) | 28 of 1,329,093 | 135.3 (max 142.5) |
dnspyre v3.12.0, 16 concurrent clients over UDP for 15 s per run, 3 runs per row after a 5 s warm-up. 'allowed' queries 5,000 unique names answered by a local CoreDNS upstream (resolver caches fill during warm-up); 'blocked' queries 5,000 names from the list. The load generator, resolver and upstream share one 4 vCPU VM, so numbers are relative. Latency values are dnspyre histogram bucket values (coarse). Rate limits were switched off on both resolvers (their defaults are 1000 queries per 60 s for Pi-hole and 20 queries per second for AdGuard Home).
With the rate limits off and Pi-hole’s /dev/shm raised to 512 MB, Pi-hole answered 31,030 allowed and 24,888 blocked queries per second at the median; AdGuard Home answered 29,292 and 29,161. The three-run ranges overlap on both workloads (allowed 26,741 to 31,440 against 25,416 to 31,376; blocked 24,472 to 28,296 against 25,931 to 33,512), so this test cannot rank the two on throughput. On median p50, AdGuard Home was lower on both workloads (426 µs against 508 µs allowed, 426 µs against 557 µs blocked). On p99 the order flips: 1,570 µs for Pi-hole against 3,280 µs for AdGuard Home on the allowed workload, and 2,230 µs against 3,150 µs on the blocked one. Whether total capacity or tail latency matters more depends on the network, and with a few dozen devices neither limit is likely to be reached. Latency values are dnspyre histogram buckets, so differences of a few percent are noise.
RAM during load is where the two diverge. AdGuard Home stayed between 125.1 and 135.3 MiB at the median. Pi-hole’s figure reached 237.2 MiB (max 382.8) on the allowed workload and 581.7 MiB (max 725.7) on the blocked one. docker stats counted all of it; the test did not break it down further.
What went wrong: Pi-hole’s first run died
The first Pi-hole container used the stack’s compose settings plus the rate limit switched off, with Docker’s default 64 MB /dev/shm. During the third allowed run, after roughly 0.98 million queries (the 5 s warm-up plus two full 15 s runs), its log filled with “Shared memory shortage (/dev/shm)” warnings, then FTL exited with No space left on device while resizing its queries object (/FTL-188-queries), and the container stopped. Every blocked-workload query after that failed.
| Run | Before load | After 'allowed' runs | After 'blocked' runs | Container at the end |
|---|---|---|---|---|
| Pi-hole 2026.10.1, Docker default /dev/shm (64 MB) | 2 of 64 MB used | unreadable (container down) | unreadable (container down) | state=exited exit_code=1 restarts=0 |
| Pi-hole 2026.10.1, shm_size 512 MB | 2 of 512 MB used | 93 of 512 MB used | 172 of 512 MB used | state=running exit_code=0 restarts=0 |
From df -m /dev/shm inside the container and docker inspect. FTL keeps its query log in shared memory.
Pi-hole’s Docker page says nothing about shm_size or shared memory; this test did not search further for guidance. The FTL log names a queries object in shared memory when it fails, so the benchmark’s query volume is the likely driver; the test did not confirm that. A second run with shm_size: 512mb completed everything (about 2.5 million queries including warm-ups) and used 172 of 512 MB by the end, which fits roughly 65 MB per million queries. Whether a real network would ever hit the 64 MB limit was not tested; with the default rate limit on, a single client could not send this volume. The point for a benchmark is that a Pi-hole container with Docker’s default /dev/shm and no rate limit does not survive this load.
Setup that was tested
The compose file below holds both resolvers behind profiles, with fixed addresses because Pi-hole’s upstream setting wants an IP:
This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
# Fixed addresses because Pi-hole upstreams must be IPs. coredns is the shared local upstream; lists serves the generated blocklist.
services:
coredns:
image: coredns/coredns:1.14.7
command: ["-conf", "/Corefile"]
volumes: ["./Corefile:/Corefile:ro"]
networks: { dnsnet: { ipv4_address: 172.28.0.10 } }
lists:
image: nginx:1.31.8-alpine
volumes: ["./lists.conf:/etc/nginx/conf.d/default.conf:ro", "./blocklist.txt:/lists/blocklist.txt:ro"]
networks: { dnsnet: { ipv4_address: 172.28.0.11 } }
pihole: &pihole
profiles: [pihole]
image: pihole/pihole:2026.10.1
environment:
TZ: UTC
FTLCONF_webserver_api_password: lab-only-not-a-secret
FTLCONF_dns_listeningMode: ALL
FTLCONF_dns_upstreams: 172.28.0.10
FTLCONF_dns_rateLimit_count: "0"
volumes: ["pihole_etc:/etc/pihole"]
networks: { dnsnet: { ipv4_address: 172.28.0.20 } }
# second attempt: same container with a larger /dev/shm (Docker's default is 64 MB)
pihole-shm:
<<: *pihole
profiles: [pihole-shm]
shm_size: 512mb
adguard:
profiles: [adguard]
image: adguard/adguardhome:v0.107.79
volumes: ["adguard_work:/opt/adguardhome/work", "adguard_conf:/opt/adguardhome/conf"]
networks: { dnsnet: { ipv4_address: 172.28.0.21 } }
networks:
dnsnet:
ipam: { config: [{ subnet: 172.28.0.0/24 }] }
volumes:
pihole_etc:
adguard_work:
adguard_conf:Pi-hole took its password and upstream from FTLCONF_* variables, then the script authenticated against its API, removed the default list, added the generated one and triggered gravity. AdGuard Home’s first-run wizard was skipped by posting to /control/install/configure, an endpoint that the pages opened for this guide do not describe, followed by API calls to set the upstream, remove the two default filters and add the list. The configuration page does describe the other route, a pre-seeded AdGuardHome.yaml, which this test did not try. The script:
This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
#!/usr/bin/env bash
# Runs inside the VM from ~/dns. Raw outputs in ~/dns/raw. One resolver at a time; coredns (upstream) and nginx (list server) stay up.
set -u
cd "$HOME/dns"; mkdir -p raw
export COMPOSE_PROJECT_NAME=dns
NET=dns_dnsnet
DNSPYRE=ghcr.io/tantalor93/dnspyre:v3.12.0
now() { date +%s.%N; }
el() { python3 -c "print(round($2 - $1, 2))"; }
log() { echo "== $* $(date -u +%T)"; }
dig() { docker run --rm --network "$NET" dnstools dig "$@"; }
docker build -q -t dnstools -f Dockerfile.dnstools . > raw/build.txt 2>&1
for i in $DNSPYRE coredns/coredns:1.14.7 nginx:1.31.8-alpine pihole/pihole:2026.10.1 adguard/adguardhome:v0.107.79; do docker pull -q $i > /dev/null; done
docker image ls --digests --format '{{.Repository}}:{{.Tag}} {{.Digest}}' > raw/images.txt
docker run --rm $DNSPYRE --version > raw/dnspyre-version.txt 2>&1
docker run --rm $DNSPYRE --help > raw/dnspyre-help.txt 2>&1
sha256sum blocklist.txt blocked-queries.txt allowed-queries.txt > raw/list-sha256.txt
wc -l blocklist.txt blocked-queries.txt allowed-queries.txt > raw/list-lines.txt
docker compose up -d coredns lists > /dev/null 2>&1; sleep 3
dig @172.28.0.10 +short anything.allowed.example.net A > raw/upstream-check.txt 2>&1
load() { # load <label> <server-ip> <queryfile> (3 runs)
local label="$1" ip="$2" qf="$3"
# warm-up so the cache is filled for the allowed workload
docker run --rm --network "$NET" -v "$PWD:/q:ro" $DNSPYRE --server "$ip" --concurrency 16 --duration 5s --no-distribution "file:///q/$qf" > /dev/null 2>&1
for i in 1 2 3; do
flag=raw/.sampling; touch $flag; : > raw/$label.run$i.stats.txt
( while [ -e $flag ]; do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' "$CID" >> raw/$label.run$i.stats.txt 2>/dev/null; done ) &
sp=$!
docker run --rm --network "$NET" -v "$PWD:/q:ro" $DNSPYRE --server "$ip" --concurrency 16 --duration 15s --no-color --no-progress "file:///q/$qf" > raw/$label.run$i.txt 2> raw/$label.run$i.err
rm -f $flag; wait $sp
done
}
answers() { # answers <label> <ip>
local label="$1" ip="$2" b a
b=$(head -1 blocked-queries.txt); a=$(head -1 allowed-queries.txt)
{ echo "# blocked name: $b"; dig @"$ip" "$b" A; dig @"$ip" "$b" AAAA +short; echo "# allowed name: $a"; dig @"$ip" "$a" A; } > raw/$label.answers.txt 2>&1
}
# ---------------- Pi-hole (run twice: Docker's default /dev/shm, then 512 MB)
run_pihole() { # run_pihole <label> <compose-service>
local L="$1" SVC="$2"
log "$L"
docker compose --profile "$SVC" up -d "$SVC" > /dev/null 2>&1; CID=$(docker compose ps -q "$SVC")
T0=$(now)
for _ in $(seq 1 180); do # any HTTP answer (the API replies 401 without a session) means the web server is up
c=$(docker run --rm --network "$NET" dnstools curl -s -m3 -o /dev/null -w '%{http_code}' http://172.28.0.20/admin/ 2>/dev/null); [ "$c" != 000 ] && [ -n "$c" ] && break; sleep 1
done
echo "api_ready_seconds $(el "$T0" "$(now)")" > raw/$L.ready.txt
PH() { docker run --rm --network "$NET" dnstools curl -s -m60 "$@"; }
SID=$(PH -X POST http://172.28.0.20/api/auth -H 'content-type: application/json' -d '{"password":"lab-only-not-a-secret"}' | jq -r .session.sid)
sleep 30 # let the first-start gravity run (default list, needs internet) finish before replacing the list
PH -H "sid: $SID" http://172.28.0.20/api/lists > raw/$L.lists-before.json
for addr in $(jq -r '.lists[].address' raw/$L.lists-before.json); do
PH -X DELETE -H "sid: $SID" "http://172.28.0.20/api/lists/$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$addr")?type=block" -o /dev/null -w "delete $addr -> %{http_code}\n"
done > raw/$L.delete-default-list.txt
PH -H "sid: $SID" -X POST "http://172.28.0.20/api/lists?type=block" -H 'content-type: application/json' \
-d '{"address":"http://172.28.0.11/blocklist.txt","comment":"generated","groups":[0],"enabled":true}' > raw/$L.add-list.json
T0=$(now)
PH -H "sid: $SID" -X POST http://172.28.0.20/api/action/gravity > raw/$L.gravity.txt 2>&1
echo "gravity_seconds $(el "$T0" "$(now)")" > raw/$L.gravity-time.txt
sleep 5
PH -H "sid: $SID" http://172.28.0.20/api/stats/summary | jq '.gravity' > raw/$L.gravity-summary.json
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' "$CID"; done > raw/$L.idle.txt
docker exec "$CID" df -m /dev/shm > raw/$L.shm-before.txt 2>&1
answers "$L" 172.28.0.20
load $L.allowed 172.28.0.20 allowed-queries.txt
docker exec "$CID" df -m /dev/shm > raw/$L.shm-after-allowed.txt 2>&1
load $L.blocked 172.28.0.20 blocked-queries.txt
docker exec "$CID" df -m /dev/shm > raw/$L.shm-after-blocked.txt 2>&1
docker inspect "$CID" --format 'state={{.State.Status}} exit_code={{.State.ExitCode}} restarts={{.RestartCount}}' > raw/$L.final-state.txt 2>&1
docker compose logs --no-color --tail=40 "$SVC" > raw/$L.logs-tail.txt 2>&1
docker compose --profile "$SVC" rm -sfv "$SVC" > /dev/null 2>&1; docker volume rm -f dns_pihole_etc > /dev/null 2>&1
}
run_pihole pihole pihole
run_pihole pihole-shm pihole-shm
# ---------------- AdGuard Home
log adguard
docker compose --profile adguard up -d adguard > /dev/null 2>&1; CID=$(docker compose ps -q adguard)
T0=$(now)
for _ in $(seq 1 120); do
c=$(docker run --rm --network "$NET" dnstools curl -s -m3 -o /dev/null -w '%{http_code}' http://172.28.0.21:3000/ 2>/dev/null); { [ "$c" = 200 ] || [ "$c" = 302 ]; } && break; sleep 1
done
echo "setup_ready_seconds $(el "$T0" "$(now)")" > raw/adguard.ready.txt
AG() { docker run --rm --network "$NET" dnstools curl -s -m120 "$@"; }
AG -X POST http://172.28.0.21:3000/control/install/configure -H 'content-type: application/json' \
-d '{"web":{"ip":"0.0.0.0","port":80},"dns":{"ip":"0.0.0.0","port":53},"username":"lab","password":"lab-only-not-a-secret"}' -o /dev/null -w 'install/configure -> %{http_code}\n' > raw/adguard.install.txt 2>&1
for _ in $(seq 1 120); do
[ "$(docker run --rm --network "$NET" dnstools curl -s -m3 -u lab:lab-only-not-a-secret -o /dev/null -w '%{http_code}' http://172.28.0.21/control/status 2>/dev/null)" = 200 ] && break; sleep 1
done
AU="-u lab:lab-only-not-a-secret"
AG $AU http://172.28.0.21/control/filtering/status > raw/adguard.filters-before.json
for u in $(jq -r '.filters[]?.url' raw/adguard.filters-before.json); do
AG $AU -X POST http://172.28.0.21/control/filtering/remove_url -H 'content-type: application/json' -d "{\"url\":\"$u\",\"whitelist\":false}" -o /dev/null -w "remove $u -> %{http_code}\n"
done > raw/adguard.remove-default-filter.txt
AG $AU -X POST http://172.28.0.21/control/dns_config -H 'content-type: application/json' \
-d '{"upstream_dns":["172.28.0.10"],"ratelimit":0}' -o /dev/null -w 'dns_config -> %{http_code}\n' > raw/adguard.dns-config.txt 2>&1
AG $AU http://172.28.0.21/control/dns_info > raw/adguard.dns-info.json
T0=$(now)
AG $AU -X POST http://172.28.0.21/control/filtering/add_url -H 'content-type: application/json' \
-d '{"name":"generated","url":"http://172.28.0.11/blocklist.txt","whitelist":false}' > raw/adguard.add-list.txt 2>&1
echo "add_url_seconds $(el "$T0" "$(now)")" > raw/adguard.add-list-time.txt
sleep 5
AG $AU http://172.28.0.21/control/filtering/status > raw/adguard.filters-after.json
for _ in $(seq 1 12); do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' "$CID"; done > raw/adguard.idle.txt
answers adguard 172.28.0.21
load adguard.allowed 172.28.0.21 allowed-queries.txt
load adguard.blocked 172.28.0.21 blocked-queries.txt
docker compose logs --no-color --tail=40 adguard > raw/adguard.logs-tail.txt 2>&1
docker compose --profile adguard down -v > /dev/null 2>&1
echo done > raw/DONEWhat this test did not cover
- Real blocklists: a generated list says nothing about match rates, false positives or how large community lists compare on parse time and RAM.
- Query logging, statistics retention and the web UIs. Both kept their default logging on during the load.
- DHCP, DoH/DoT/DoQ, DNSSEC, per-client rules, allowlists and regex filters.
- Upstreams over the internet: the upstream was local, so recursive resolution time is absent.
- A real client mix: dnspyre sent 5,000 unique names repeatedly, so resolver caches were mostly warm.
- Long runs: each workload ran 15 s per measurement, three times. Memory growth over days is unknown, apart from the shared-memory finding above.
- Arm devices and Raspberry Pi hardware.
Sources and how this was made
Show the full file
#!/usr/bin/env bash
# dns-bench: Pi-hole and AdGuard Home load the same generated blocklist, same local upstream, same dnspyre workload.
TEST_ID=dns-bench; VM_NAME=h-dns-bench
source "$(dirname "$0")/../access-common.sh"
HERE="$ROOT/lab/tests/$TEST_ID"
rm -rf "$RAW"; mkdir -p "$RAW"
WORK="$(mktemp -d)"
cp "$HERE/files/"* "$WORK/"; (cd "$WORK" && python3 -I gen-lists.py)
vm_start
capture_env
vm ssh 'rm -rf ~/dns; mkdir -p ~/dns/raw'
vm scp-to "$WORK/." dns/
vm scp-to "$HERE/dns.sh" dns/dns.sh
vm ssh 'cd ~/dns && nohup ./dns.sh > raw/dns.log 2>&1 &'
for _ in $(seq 1 180); do
vm ssh 'test -f ~/dns/raw/DONE' 2>/dev/null && break
sleep 10
done
vm scp-from dns/raw/. "$RAW/"
rm -rf "$WORK"
python3 -I "$HERE/summarize.py" "$RAW" "$OUT"- Pi-hole docs: Docker
- Pi-hole docs: FTL configuration
- Pi-hole docs: Blocking mode
- AdGuard Home wiki: Configuration
- dnspyre
How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.