On this page (10)
  1. Side-by-side measured table
  2. Test method
  3. What the idle RAM figure includes
  4. Features compared (researched)
  5. When to pick Pi-hole
  6. When to pick AdGuard Home
  7. Migration notes
  8. Compose files
  9. What we did not test
  10. Related

RAM should not decide this: both fit a 1 GB machine by our sizing rule. In our lab Pi-hole v6 (2026.10.0) idled at 9.3 MiB (peak 18.2) and AdGuard Home v0.107.79 at 39.3 MiB (peak 61.4), medians of three runs. Pick AdGuard Home if you want the app itself to serve encrypted DNS (DoH, DoT, DoQ) or offer parental controls (researched, not tested). Pick Pi-hole if plain DNS is enough and you want the smaller footprint. Neither dropped a query (0 timeouts in 32,900 per run). The apps ran on different CPU models, so latency is not a head-to-head.

Running Pi-hole 2026.09.0 or older? Update to 2026.10.0. The 2026.10.0 release notes (Oct 17, 2026) say the release “includes fixes for 17 security advisories across FTL, Web and Core”, five of them rated High, and list no breaking changes. The compose file on this page now pins 2026.10.0. If your file pins an older tag, change the tag on the image: line to pihole/pihole:2026.10.0 (a floating tag needs no edit), then run these two commands. We did not test any of the advisories, and we say nothing about whether your setup is exposed.

Not run in our lab. The lab started fresh 2026.10.0 stacks and did not upgrade a running 2026.09.0 one.

bash
docker compose pull
docker compose up -d

Pi-hole’s low idle figure is Docker’s working-set number, which depends on page-cache state: a side check on a different host gave a 2.3x gap instead of the lab’s 4.2x (see what the idle RAM figure includes). We did not test DNS-over-HTTPS or DNS-over-TLS, DHCP, real clients, or Arm hardware such as a Raspberry Pi: the lab used one x86-64 runner and one synthetic DNS client.

Measured readings

Test box: Pi-hole v6 2026.10.0

Measured in our lab on : 3 full runs, each from a fresh stack. Each figure is the median, with the range over the runs under it. The numbers come from the raw result file.

Idle RAM
9 MiBsame when rounded in all 3 runs all containers, median of 3 runs; each run is the mean over 30 s after the stack settled
Startup peak RAM
12 MiBrange 10–13 · 3 runs highest total between healthy and settled
Peak RAM
18 MiBrange 18–19 · 3 runs under load, median of the run peaks
Settled after
123 ssame when rounded in all 3 runs after the first healthy response
Idle CPU
0.2 %same when rounded in all 3 runs of one core, lab host
Startup
1.6 srange 1.6–4.2 · 3 runs to first healthy response
App version
2026.10.0
Tested
, 3 runs (compose file verified the same day)
How the figures are made
Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
Lab host
AMD EPYC 9V74 80-Core Processor, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
Disk for the images
About 157 MB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 100 MB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
How idle was measured
After the stack was healthy and set up, we sampled total RAM until it stayed within max(2% of its mean, 10 MiB) over 60 s (at least 120 s, at most 600 s), then measured idle over the next 30 s. Rule in full
Peak RAM measured
Under load. A Python script (standard library only) in a container on the Compose network sends plain UDP DNS queries (type A) to Pi-hole's port 53. The same script and the same 900 names run against AdGuard Home: 500 real popular domains, 300 names from the StevenBlack blocklist that both apps load (so they are blocked and answered 0.0.0.0) and 100 random .com names that do not exist (NXDOMAIN). Three phases, queries sent on a fixed schedule whether or not earlier ones were answered: every name once at 100 queries/s (first-time latency, 9 s); 200 queries/s for 60 s, names drawn in shuffled rounds so most are repeats; then a burst of 1,000 queries/s for 20 s. A query not answered within 2 s is a timeout. The script records queries sent, answered, timeouts, blocked answers and the p50, p95 and p99 latency of first-time and of repeated (cache-expected) queries, and reads the resolver's own query counters before and after. No real clients, no DoH/DoT, no DHCP, no reverse lookups.
Per-container figures for Pi-hole v6: median over 3 runs, with the range
ContainerIdle RAMPeak RAM (under load)Image, compressed (download)Image, unpacked (disk, overlay2 store)
pihole9 MiBsame when rounded in all 3 runs18 MiBrange 18–19 · 3 runs43 MB100 MB
Every run (3)
Total RAM, startup and CPU model of each run of Pi-hole v6
RunIdle RAMPeak RAMStartup peakStartupLab host CPU
19 MiB19 MiB12 MiB1.6 sAMD EPYC 9V74 80-Core Processor
2 (closest to median idle)9 MiB18 MiB10 MiB1.6 sAMD EPYC 9V74 80-Core Processor
39 MiB18 MiB13 MiB4.2 sAMD EPYC 9V74 80-Core Processor
Total RAM after the stack became healthy (10 s averages, run 2 of 3)
Total RAM across all containers in 10 s averages, from the moment the stack was healthy until the end of the idle window (160 s). Run 2 of 3, the one closest to the median idle. Highest 10 s average 9 MiB at 10 s, last 9 MiB at 160 s. Dashed line: the stack counted as settled at 123 s.

0 s to 160 s after healthy; the line spans 0 to 9 MiB. Dashed line: settled.

Timeline as a table
Total RAM, 10 s average ending at each time, every 30 s after healthy
Seconds after healthyTotal RAM
309 MiB
609 MiB
909 MiB
1209 MiB
1509 MiB
1609 MiB

RAM is MiB (220 bytes); image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. Per-container peaks happen at different moments, so they do not add up to the total peak. Medians of separate columns need not add up either.

Lab notes (6)
  • Host: a GitHub Actions runner (ubuntu-latest, x86_64, 2 vCPU, 8 GB RAM; the CPU model varies between runners (AMD EPYC and Intel Xeon models so far) and each run records its own). Not a Raspberry Pi: no Arm result, and the numbers are valid for that host only.
  • Measured: idle RAM and CPU after start-up with the default list loaded and no client; RAM and CPU while one client sends the DNS load of load.description (200 queries/s for 60 s, then 1,000 queries/s for 20 s); image size; start-up time; answer latency seen by that client. Not measured: real clients or a real household's query mix, DNS-over-HTTPS or DNS-over-TLS, DHCP, IPv6, DNSSEC validation, long-term growth of the query database, adlists beyond the default one, Arm hardware.
  • Fair comparison: both apps use the same upstream resolver (1.1.1.1, plain UDP), the same single blocklist (StevenBlack unified hosts, Pi-hole's default list) and the same blocking answer (0.0.0.0). The upstream resolver's response time varies from run to run, so the first-time latency (which includes it) is not comparable between runs or apps; the latency of repeated queries that the resolver should answer from its cache is. The script counts a repeat as cache-expected when an earlier answer for that name arrived less than half its TTL ago.
  • Settings changed from the app's defaults, in setup: upstream 1.1.1.1 (default 8.8.8.8 and 8.8.4.4) and the rate limit off (default 1,000 queries per 60 s per client). Left at default: query logging on, cache size 10,000 entries, DNSSEC off, blocking mode NULL. Settings in the published compose: listening mode ALL (needed on Docker's bridge network), password, time zone.
  • The 'domains loaded' figure is Pi-hole's gravity count (unique domains after de-duplication). AdGuard Home reports rules, which also count a few non-domain lines of the hosts file; the two numbers are close but not the same quantity.
  • Lab-only compose override compose.lab.yaml (NOT in the published compose): The lab run publishes no host ports (53, 80 and 443 of the published file are not mapped): the runner's systemd-resolved listens on 127.0.0.53:53 and Docker's port 53 publishing can clash with it, and the setup and load steps reach Pi-hole by service name over the Compose network instead. Image, environment, volume and capabilities are the published file's. The published file has not been run with its ports mapped in the lab.

Measured readings

Test box: AdGuard Home v0.107.79

Measured in our lab on : 3 full runs, each from a fresh stack. Each figure is the median, with the range over the runs under it. The numbers come from the raw result file.

Idle RAM
39 MiBrange 39–43 · 3 runs all containers, median of 3 runs; each run is the mean over 30 s after the stack settled
Startup peak RAM
42 MiBrange 41–46 · 3 runs highest total between healthy and settled
Peak RAM
61 MiBrange 59–62 · 3 runs under load, median of the run peaks
Settled after
122 srange 122–125 · 3 runs after the first healthy response
Idle CPU
0 %same when rounded in all 3 runs of one core, lab host
Startup
0.4 ssame when rounded in all 3 runs to first healthy response
App version
v0.107.79
Tested
, 3 runs (compose file verified the same day)
How the figures are made
Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
Lab host
AMD EPYC 9V45 96-Core Processor, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
Disk for the images
About 123 MB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 82 MB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
How idle was measured
After the stack was healthy and set up, we sampled total RAM until it stayed within max(2% of its mean, 10 MiB) over 60 s (at least 120 s, at most 600 s), then measured idle over the next 30 s. Rule in full
Peak RAM measured
Under load. The same script and the same 900 names as the Pi-hole app, against AdGuard Home's port 53 over plain UDP (type A) from a container on the Compose network: 500 real popular domains, 300 names from the StevenBlack blocklist (blocked, answered 0.0.0.0) and 100 random .com names that do not exist (NXDOMAIN). Three phases, queries sent on a fixed schedule whether or not earlier ones were answered: every name once at 100 queries/s (first-time latency, 9 s); 200 queries/s for 60 s, names drawn in shuffled rounds so most are repeats; then a burst of 1,000 queries/s for 20 s. A query not answered within 2 s is a timeout. The script records queries sent, answered, timeouts, blocked answers and the p50, p95 and p99 latency of first-time and of repeated (cache-expected) queries, and reads the resolver's own query counters before and after. No real clients, no DoH/DoT, no DHCP, no reverse lookups.
Per-container figures for AdGuard Home: median over 3 runs, with the range
ContainerIdle RAMPeak RAM (under load)Image, compressed (download)Image, unpacked (disk, overlay2 store)
adguardhome39 MiBrange 39–43 · 3 runs61 MiBrange 59–62 · 3 runs30 MB82 MB
Every run (3)
Total RAM, startup and CPU model of each run of AdGuard Home
RunIdle RAMPeak RAMStartup peakStartupLab host CPU
1 (closest to median idle)39 MiB59 MiB41 MiB0.4 sAMD EPYC 9V45 96-Core Processor
243 MiB62 MiB46 MiB0.4 sAMD EPYC 9V45 96-Core Processor
339 MiB61 MiB42 MiB0.4 sAMD EPYC 9V45 96-Core Processor
Total RAM after the stack became healthy (10 s averages, run 1 of 3)
Total RAM across all containers in 10 s averages, from the moment the stack was healthy until the end of the idle window (160 s). Run 1 of 3, the one closest to the median idle. Highest 10 s average 41 MiB at 20 s, last 39 MiB at 160 s. Dashed line: the stack counted as settled at 125 s.

0 s to 160 s after healthy; the line spans 0 to 41 MiB. Dashed line: settled.

Timeline as a table
Total RAM, 10 s average ending at each time, every 30 s after healthy
Seconds after healthyTotal RAM
3041 MiB
6041 MiB
9039 MiB
12039 MiB
15039 MiB
16039 MiB

RAM is MiB (220 bytes); image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. Per-container peaks happen at different moments, so they do not add up to the total peak. Medians of separate columns need not add up either.

Lab notes (6)
  • Host: a GitHub Actions runner (ubuntu-latest, x86_64, 2 vCPU, 8 GB RAM; the CPU model varies between runners (AMD EPYC and Intel Xeon models so far) and each run records its own). Not a Raspberry Pi: no Arm result, and the numbers are valid for that host only.
  • Measured: idle RAM and CPU after the wizard and one filter list with no client; RAM and CPU while one client sends the DNS load of load.description (200 queries/s for 60 s, then 1,000 queries/s for 20 s); image size; start-up time; answer latency seen by that client. Not measured: real clients or a real household's query mix, DNS-over-HTTPS or DNS-over-TLS, DHCP, IPv6, DNSSEC, long-term growth of the query log and statistics, filter lists beyond the one, Arm hardware.
  • Fair comparison: both apps use the same upstream resolver (1.1.1.1, plain UDP), the same single blocklist (StevenBlack unified hosts, Pi-hole's default list) and the same blocking answer (0.0.0.0). The upstream resolver's response time varies from run to run, so the first-time latency (which includes it) is not comparable between runs or apps; the latency of repeated queries that the resolver should answer from its cache is. The script counts a repeat as cache-expected when an earlier answer for that name arrived less than half its TTL ago.
  • Settings changed from the app's defaults, in setup: upstream 1.1.1.1 plain UDP (default: DNS-over-HTTPS to Quad9), rate limit off (default 20 queries/s per client), DNSSEC flag off (default on), blocking mode null_ip (default: 'default'), the default filters AdGuard DNS filter and AdAway removed and the StevenBlack list added. Left at default: query log and statistics on, cache on at 4 MiB, upstream mode load balancing (one upstream, so no effect).
  • The 'rules loaded' figure is AdGuard Home's rules_count for the list: it counts a few non-domain lines of the hosts file (localhost entries) that Pi-hole's gravity count leaves out; the two numbers are close but not the same quantity.
  • Lab-only compose override compose.lab.yaml (NOT in the published compose): The lab run publishes no host ports (53, 80 and 3000 of the published file are not mapped): the runner's systemd-resolved listens on 127.0.0.53:53 and Docker's port 53 publishing can clash with it, and the setup and load steps reach AdGuard Home by service name over the Compose network instead. Image and volumes are the published file's. The published file has not been run with its ports mapped in the lab.

Side-by-side measured table

Source for every row marked Tested: pihole.json and adguard-home.json, runs of Oct 19, 2026. Each figure is the median of three runs, with the lowest to highest run in brackets. Each app ran in its own GitHub Actions job (2 vCPU, 7.8 GiB RAM, Ubuntu 24.04.5, Docker 28.0.4), and all three runs of an app landed on one CPU model, shown in the first row. Because the CPUs differ, the CPU, startup and latency rows are not like for like; the RAM and image rows carry across hosts. The Oct 13 runs are replaced; the old figures are in the update log and lab/notes/dns-2026-10-19.md. RAM is Docker’s working set, not the host’s view of the process; read what the idle RAM figure includes before you quote it.

Row Pi-hole 2026.10.0 (FTL v6.8) AdGuard Home v0.107.79 Source
Lab host CPU (2 vCPU) AMD EPYC 9V74 AMD EPYC 9V45 Tested
Idle RAM, MiB 9.27 (9.15 to 9.42) 39.25 (39.23 to 43.27) Tested
Startup peak RAM, MiB (healthy to settled) 11.66 (10.02 to 12.67) 41.80 (41.41 to 45.82) Tested
Peak RAM under load, MiB (up to 1,000 queries/s) 18.18 (17.50 to 18.67) 61.37 (59.47 to 61.53) Tested
Idle CPU, % of one core, lab host 0.18 (0.16 to 0.18) 0.04 (0.01 to 0.04) Tested
CPU during the load, mean, % of one core, lab host 4.48 (4.37 to 4.56) 3.61 (3.60 to 3.66) Tested
CPU during the load, highest sample, % of one core, lab host 16.53 (16.38 to 16.53) 11.71 (11.14 to 12.81) Tested
Timeouts (2 s limit) 0 of 32,900 queries in each of 3 runs 0 of 32,900 queries in each of 3 runs Tested
Blocklist entries loaded (StevenBlack unified hosts, Oct 19 snapshot) 71,493 domains (gravity count) 71,506 rules (rules_count) Tested
Startup to first healthy check, s, lab host 1.6 (1.6 to 4.2) 0.4 (0.4 to 0.4) Tested
Image, compressed / unpacked, MB 42.9 / 99.5 29.5 / 81.5 Tested

Four notes on reading the table.

  • The blocklist rows are close, not identical quantities. Pi-hole’s figure is the unique domains in its gravity database. AdGuard Home’s is the rules it parsed from the same hosts file, which also counts a few non-domain lines such as the localhost entries (lab note in both result files).
  • The blocklist is newer than in our Oct 13 runs. The StevenBlack file is fetched from the project’s master branch at each run, so two runs on different days can load different snapshots. On Oct 13 the apps loaded 72,526 domains and 72,539 rules; on Oct 19 they loaded 71,493 and 71,506, which is 1,033 fewer each. Our 300 blocklist test names were taken from the file dated Oct 7, 2026, and 38 of them are no longer on the list, so both apps answered those 38 normally. In every Oct 19 run both apps blocked the same 262 of the 300 names and the same 9,581 answers out of 32,900 queries (10,975 on Oct 13, when all 300 were listed). We re-ran both apps on Oct 19, so this pair loaded one snapshot (the counts differ by the same 13 as on Oct 13, and the blocked counts match); we did not look at why the file shrank. Pi-hole’s RAM did not move with the list (idle 9.27 MiB on both dates); AdGuard Home’s did move, but it also moved to a different CPU model, so we do not attribute that change.
  • Startup times do not mean the same thing. Pi-hole’s check is its web API answering (HTTP 200), after the first start has built its list. AdGuard Home’s check is its setup wizard answering on port 3000, before any setup. AdGuard Home was not ready to resolve at 0.4 s, so we do not rank on this row.
  • The peak is the highest single sample of the container’s memory during the roughly 100-second load, sampled every 2 to 3 seconds, so a shorter spike can be missed. By our sizing rule (the highest run peak, 18.7 MiB and 61.5 MiB, times 1.5, rounded up to 1, 2, 4, 8, 16 or 32 GB, how we size) both stacks need 1 GB.

Answer latency for repeated queries

“Repeated” means a query for a name that had been answered less than half its TTL earlier, which the resolver should serve from its cache; blocked names are answered from the blocklist. Both come from the lab’s DNS script (see Test method). Milliseconds, median of three runs with the range in brackets, lab host only. The blocked-name rows cover the 262 names that were still listed on Oct 19. Source: Tested (pihole.json, adguard-home.json).

Query set and phase Percentile Pi-hole (ms, lab host) AdGuard Home (ms, lab host)
Real names, repeated, 200 queries/s for 60 s p50 0.111 (0.107 to 0.112) 0.081 (0.080 to 0.081)
Real names, repeated, 200 queries/s for 60 s p99 0.235 (0.212 to 0.237) 0.177 (0.172 to 0.177)
Real names, repeated, 1,000 queries/s for 20 s p50 0.100 (0.099 to 0.100) 0.075 (0.075 to 0.077)
Real names, repeated, 1,000 queries/s for 20 s p99 0.220 (0.202 to 0.291) 0.176 (0.149 to 0.187)
Blocked names, 200 queries/s p50 0.102 (0.100 to 0.103) 0.076 (0.075 to 0.077)
Blocked names, 200 queries/s p99 0.167 (0.161 to 0.188) 0.166 (0.157 to 0.187)
Blocked names, 1,000 queries/s p50 0.094 (0.093 to 0.094) 0.071 (0.071 to 0.072)
Blocked names, 1,000 queries/s p99 0.156 (0.145 to 0.252) 0.137 (0.136 to 0.140)

What the rows say, and what they do not:

  • On their different CPUs, AdGuard Home’s median was the lower one on all eight rows, by 0.001 to 0.058 ms. On seven rows the three-run ranges do not overlap; on blocked names at 200 queries/s the p99 is a tie (0.167 against 0.166 ms, ranges overlapping).
  • That is a change from our Oct 13 runs, where neither app was faster on every row: Pi-hole’s blocked-name median was lower then (0.177 against 0.201 ms at 200 queries/s) and AdGuard Home’s real-name p99 was lower. Both apps’ repeated-query medians fell by about half or more between the two dates (real names at 200 queries/s: Pi-hole 0.219 to 0.111 ms, AdGuard Home 0.216 to 0.081 ms). The AdGuard Home image is the same digest on both dates, so that change came from the host or the run, not the software. We cannot say how much of the new gap between the apps is the app and how much is the CPU.
  • Every figure is a fraction of a millisecond. We would not choose between the two on these rows, because of the CPU difference above and because we sent from one client with one query mix.
  • First-time lookups are not ranked. Each name’s first lookup waits for 1.1.1.1 and the network, and the lab note in both result files says that latency is not comparable between runs or apps. The spread between days shows why: the p50 for first-time real names was 6.0 to 6.4 ms in Pi-hole’s three Oct 19 runs and 20.5 to 20.8 ms in AdGuard Home’s, but on Oct 13 it was 25.3 to 25.9 ms in Pi-hole’s and 10.6 to 17.4 ms in AdGuard Home’s. The order reversed, on separate runners.

Test method

Each app had its own lab job on Oct 19, 2026 (GitHub Actions ubuntu-latest, 2 vCPU, 7.8 GiB RAM, Ubuntu 24.04.5, Docker 28.0.4, Compose 2.38.2, x86-64) and three full runs, each from a fresh stack: start, health check, setup, wait for memory to settle (about 122 to 125 s after the first healthy check), a 30-second idle window, the DNS load, teardown with the volumes removed (methodology). The images were pihole/pihole:2026.10.0 and adguard/adguardhome:v0.107.79, pinned by digest in the result files.

What we made equal. One upstream resolver for both, 1.1.1.1 over plain UDP. One blocklist for both, the StevenBlack unified hosts file (the list Pi-hole ships with), fetched at each run, so the Oct 19 pair loaded one snapshot. The same blocked answer, 0.0.0.0. DNSSEC off. Query rate limit off. Query logging left on in both, and each app’s own default cache left as it is (10,000 entries in Pi-hole, 4 MiB in AdGuard Home).

What that changed from each app’s defaults (lab notes in the result files, from lab/apps/pihole/test.yaml and lab/apps/adguard-home/test.yaml):

  • Pi-hole: upstream 1.1.1.1 (the Docker image falls back to 8.8.8.8 and 8.8.4.4 when none is set, per its start-up script) and rate limit off (default 1,000 queries per 60 s per client).
  • AdGuard Home: upstream 1.1.1.1 over plain UDP (a new install uses DNS-over-HTTPS to Quad9), rate limit off (default 20 queries/s per client), DNSSEC flag off (default on), blocking mode null_ip (default: default), and its two default filters, AdGuard DNS filter and AdAway Default Blocklist, removed in favor of the one list.
  • So our AdGuard Home numbers are for plain-UDP upstream. We did not measure its default encrypted upstream, which adds its own work.

The load. One Python script (standard library only) in a python:3.12-slim container on the Compose network sent type A queries over UDP to port 53. The same script and the same 900 names (the same list as on Oct 13) went to both apps: 500 real popular domains, 300 names taken from the blocklist file of Oct 7 (answered 0.0.0.0 while still listed), and 100 random .com names that do not exist (NXDOMAIN). Three phases on a fixed schedule: every name once at 100 queries/s (9 s); 200 queries/s for 60 s, with names drawn in shuffled rounds so most are repeats; then 1,000 queries/s for 20 s. That is 32,900 queries per run. A query not answered within 2 s counts as a timeout. In every Oct 19 run both apps blocked 262 of the 300 blocklist names, the other 38 were no longer on the list (see the blocklist note under the table), and no other name was blocked.

Ports. The published compose files below publish port 53, but the lab run used a Compose override with no host ports: the runner’s systemd-resolved listens on 127.0.0.53:53, which can clash with Docker’s port 53 publishing, and the setup and load steps reached each app by service name over the Compose network. Image, environment, volumes and capabilities were the published files’. The published files, with their ports mapped, have not been run in our lab.

Setup. Pi-hole’s setup used its API. AdGuard Home’s first-run wizard was done through its documented install API, not in a browser.

What the idle RAM figure includes

The lab reports each container’s memory as Docker’s working set: usage minus inactive file cache (definitions.memory in both result files). Pi-hole’s 9.3 MiB is low enough to question, so on Oct 14, 2026 we looked at it by hand (on the Oct 13 setup). This is a manual check in our sandbox, not one of the lab runs, and the sandbox is a different environment from the lab runner: 4 vCPUs, kernel 6.18, Docker 29.6.2 and cgroup v1. It ran the two image digests of the Oct 13 lab runs (Pi-hole 2026.09.0 and AdGuard Home v0.107.79), one container at a time, with no DNS traffic, one reading about 125 seconds after start. The raw output is in lab/notes/dns-2026-10-14.md (sections B and C). We did not repeat it on Pi-hole 2026.10.0; the lab’s idle median for it is 9.27 MiB, the same as for 2026.09.0, and the AdGuard Home image is unchanged.

The blocklist is not held in RAM as a list. For the Oct 13 list of 72,526 domains, Pi-hole’s gravity.db is a 4,337,664-byte SQLite file (4.1 MiB). The FTL process maps the whole file read-only, and 128 kB of it was resident at idle. Pi-hole’s database docs say it uses SQLite for its domain tables. In the same check AdGuard Home’s process held about 24 MiB of anonymous memory with its 72,539 rules (Oct 13 list) loaded; we did not break that down.

Pi-hole’s main process is small. pihole-FTL had a resident set of 10.8 to 10.9 MiB: about 6.4 MiB of that is the FTL executable’s own file pages, 2.3 MiB is heap, and the rest is shared-memory tables and the mapped databases. Nothing large is hiding outside the process.

The Docker figure depends on the page cache. Pi-hole’s image holds many files (Alpine, shell scripts, git, curl, the FTL binary). With a warm page cache the container is charged for few of them; right after we dropped the cache, it was charged for about 47 MiB of active file cache. AdGuard Home is one static binary, so its figure barely moved. Cache pages are reclaimable, so that is not memory the app needs, but Docker counts the active ones.

Sandbox case (one reading, about 125 s after start) Docker working set (MiB) Anonymous memory (MiB) Main process resident set (MiB)
Pi-hole, page cache warm 12.24 4.38 10.8
Pi-hole, page cache dropped before start 56.72 4.37 10.9
AdGuard Home, page cache warm 27.63 24.37 42.8
AdGuard Home, page cache dropped before start 25.43 24.27 41.1

Pi-hole’s anonymous memory leaves out 1.9 MiB of shared memory in /dev/shm (FTL’s tables), which Docker’s figure counts. Source: manual check, one reading per row, not a lab run.

What we could not reproduce. The sandbox gave neither lab figure. Pi-hole read 12.2 MiB warm against the lab’s 9.3. AdGuard Home read 25.4 to 27.6 MiB against the lab’s 39.3 (45.3 on Oct 13), and we could not explain that gap of 12 to 14 MiB (18 to 20 against the Oct 13 figure) from the data. The same AdGuard Home image idled 6 MiB lower on the Oct 19 runner than on the Oct 13 one, and we did not find out why. One consistent explanation, not proven: AdGuard Home’s executable pages (16.8 MiB in the dropped-cache case) were counted in its working set on the runner and not in the sandbox. We did not check the runner’s cgroup version.

What holds. Pi-hole was the smaller app in every view we looked at, but the ratio is not fixed. AdGuard Home used about 4.2 times Pi-hole’s RAM by the lab’s Docker figures (4.9 times on Oct 13), about 2.3 times by the sandbox’s warm Docker figures, and about 3.8 to 4.0 times by process resident set. Treat “about a quarter of the RAM” (24%) as the lab’s number; the sandbox views put Pi-hole at a quarter to 44% of AdGuard Home’s.

What the idle figure leaves out. Inactive file cache. The first start’s gravity build: the lab’s definition for Pi-hole says the first start downloads and builds the list before FTL starts, which is before the first healthy check, and we did not sample memory during it. Growth of the query database over days. Any traffic from real clients. We did not break down Pi-hole’s load peak of 18.2 MiB.

Features compared (researched)

We did not test these. Each row is from the project’s own documentation or registry, read Oct 14, 2026 (the links’ status was re-checked on Oct 19; the pages were not re-read), except the two marked Tested.

Feature Pi-hole v6 AdGuard Home Source
Encrypted DNS served to your devices (DoH, DoT, DoQ) No built-in server in the Pi-hole docs we read Built in: “all modern DNS encryption protocols out-of-the-box”; needs a domain name and a certificate (AdGuard Home encryption docs) Researched
Encrypted upstream Through a separate proxy: the docs’ cloudflared guide points Pi-hole at 127.0.0.1#5053, and says Cloudflare deprecated the feature it relies on in November 2025 and that new installs by that method are not recommended Upstream addresses can start with tls://, https://, h3://, quic:// or sdns:// (configuration docs) Researched
DHCP server Embedded, off by default; in Docker it needs port 67/udp and the NET_ADMIN capability (Docker docs, configuration) Built in; the host needs a static IP (DHCP docs); the Docker wiki says to use --network host for DHCP (Docker wiki) Researched
Web interface and API v6 builds both into pihole-FTL, which “eliminates the need for lighttpd and PHP” (v6 announcement) Web interface and an HTTP API; the lab’s setup script drove the install wizard and the filter lists through it Researched, API use Tested
Configuration file /etc/pihole/pihole.toml (TOML); a setting given as an FTLCONF_ environment variable becomes read-only (configuration) AdGuardHome.yaml (YAML); edit it only while the app is stopped, or the running program overwrites it (configuration docs) Researched
Rules per client Groups link clients, lists and domains (group management) “Per-client (device) configuration” (README) Researched
Parental control and safe search No built-in feature in the Pi-hole docs we read; you can assign third-party adult-site lists to a group (groups) Parental control, forced safe search for named search engines, and blocked services with an optional schedule (configuration docs) Researched
Default list on a new install StevenBlack unified hosts (our setup script checked that this is the only list) AdGuard DNS filter and AdAway Default Blocklist (our setup script removed both) Tested
Default query rate limit 1,000 queries per 60 s per client (configuration) 20 queries per second per client (configuration docs) Researched
License EUPL v1.2 (LICENSE) GPL-3.0 (LICENSE) Researched
Image platforms (registry manifest) linux/386, amd64, arm/v6, arm/v7, arm64, riscv64 linux/386, amd64, arm/v6, arm/v7, arm64, ppc64le Researched
Documented minimum 512 MB RAM and 2 GB free space (prerequisites) None found in the getting started docs Researched

The two rate-limit defaults matter for one reason: a single busy client can hit either one. We switched both off for the load, so our numbers say nothing about what happens at the defaults.

Registry platforms were read with docker buildx imagetools inspect on Oct 14, 2026 (lab/notes/dns-2026-10-14.md, section D) for Pi-hole 2026.09.0 and AdGuard Home v0.107.79. For Pi-hole 2026.10.0 the Docker Hub manifest read on Oct 19 lists the same six platforms (lab/notes/dns-2026-10-19.md, section D). Both lists include 64-bit Arm. We ran x86-64 only, so there is no Arm number here.

When to pick Pi-hole

  • You have well under 1 GB to spare. Idle 9.3 MiB and peak 18.2 MiB, against 39.3 and 61.4 MiB (Tested; read the caveat on what the figure includes). By our sizing rule both fit 1 GB, so the gap matters only on smaller boxes, which we did not test. Pi-hole’s prerequisites page documents 512 MB of RAM; the AdGuard Home docs we read state no minimum.
  • You want per-group list assignment. Pi-hole’s groups tie clients, lists and domains together (researched, not tested by us).
  • You prefer one binary for DNS, web interface and API. v6 dropped lighttpd and PHP (announcement). If you are on Pi-hole 5, the same post calls the upgrade “strictly a one-way operation”: back up first.
  • You are fine with plain upstream DNS, or will run a separate encrypted proxy. The docs’ cloudflared route is deprecated as of November 2025 (see the table), so check what you would use before you rely on it.

When to pick AdGuard Home

  • You want encrypted DNS from the app itself. DoH, DoT and DoQ server support is built in, and encrypted upstreams are a matter of an address prefix (researched, not tested). In our lab AdGuard Home ran with plain UDP upstream, so we have no number for either encrypted mode.
  • You want parental controls. Parental control, forced safe search and scheduled blocked services are in its configuration (researched).
  • You want a first-run wizard and one YAML file. Its image has no password variable: the first start serves a setup wizard on port 3000 (compose file below). Not run in our lab: the wizard in a browser.

Migration notes

Not run in our lab: we did not move a setup from one app to the other, and we did not look for a converter. Two things we do know. Both apps loaded the same list URL and counted nearly the same entries (71,493 domains and 71,506 rules on Oct 19), so list subscriptions carry over by pasting the URL. Their data lives in named volumes in the files below: pihole-etc for Pi-hole, adguard-work and adguard-conf for AdGuard Home. Client rules, allow and deny entries, DHCP leases and local DNS records are not covered by anything the lab ran.

If you run Pi-hole 5 today, the v6 announcement says the upgrade cannot be reversed, so back up the configuration before you take it.

Compose files

Both files are the ones the lab ran, with their ports as published. The lab run itself used an override that published none (see Test method). They are not byte-identical to what the lab ran in one respect: Pi-hole’s .env has the lab’s password removed, because it is public. Compose refuses to start the Pi-hole stack without PIHOLE_PASSWORD, so set your own before the first start. Pi-hole accepts any text as a password, so a placeholder left in place would be a known password. The measured runs used the lab’s own.

compose.yamlVerified 2026-10-19 · Pi-hole v6 2026.10.0Download compose.yaml for Pi-hole v6
# Verified compose for Pi-hole v6, tested 2026-10-19 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: AMD EPYC 9V74 80-Core Processor, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: pihole.json (schema 3).
# Lab difference: The lab run publishes no host ports (53, 80 and 443 of the published file are not mapped): the runner's systemd-resolved listens on 127.0.0.53:53 and Docker's port 53 publishing can clash with it, and the setup and load steps reach Pi-hole by service name over the Compose network instead. Image, environment, volume and capabilities are the published file's. The published file has not been run with its ports mapped in the lab.
# Companion env file: pihole.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In pihole.env:
#   PIHOLE_PASSWORD is commented out. Uncomment it and set your own value (the comment above it explains how).

# Pi-hole v6 (official image pihole/pihole) in Docker Compose: a network-wide DNS ad blocker.
# Follows the Quick Start of https://github.com/pi-hole/docker-pi-hole (README, tag 2026.10.0) and
# https://docs.pi-hole.net/docker/configuration/ . Differences from the README example, each marked below:
# the image tag is pinned, the password comes from .env, the data lives in a named volume, and only
# the capability Pi-hole needs without DHCP or NTP is added.
# There is no TLS in front of the web interface except the self-signed certificate Pi-hole makes for port 443.
# Do not expose ports 53 or 80 to the internet: an open resolver is used for DDoS reflection.
name: pihole

services:
  pihole:
    image: pihole/pihole:2026.10.0  # sha256:d34134716fc32038b052a8674c7c6879e0ff21f0e6d8af345166fffa2c27c113  (version 2026.10.0, from label:org.opencontainers.image.version)
    restart: unless-stopped
    ports:
      # DNS, for the devices on your network. If the host's own DNS (systemd-resolved on Ubuntu and Debian)
      # already listens on port 53, Docker cannot bind it: see "Port 53 is already in use" in the article.
      - "53:53/tcp"
      - "53:53/udp"
      # Web interface and API over HTTP, and over HTTPS (FTL generates a self-signed certificate).
      # Change the left-hand numbers if another service on the host already uses 80 or 443.
      - "80:80/tcp"
      - "443:443/tcp"
      # Add these only if you use Pi-hole as the DHCP server (it also needs NET_ADMIN below) or as an NTP server:
      #- "67:67/udp"
      #- "123:123/udp"
    environment:
      # Your time zone (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones): logs rotate at local midnight.
      TZ: "America/New_York"
      # Password for the web interface and the API. Set in .env; without one Pi-hole would assign a random password.
      FTLCONF_webserver_api_password: "${PIHOLE_PASSWORD:?set PIHOLE_PASSWORD in .env}"
      # Docker's default bridge network: Pi-hole must answer queries that arrive from outside its own subnet.
      # Without this, v6 ignores them. (README: "should be set to 'ALL'".)
      FTLCONF_dns_listeningMode: "ALL"
    volumes:
      # Pi-hole's databases, lists and settings (pihole.toml). Back this volume up before an upgrade.
      - pihole-etc:/etc/pihole
    cap_add:
      # Optional: lets Pi-hole get more processing time. The README example also lists NET_ADMIN (only for DHCP)
      # and SYS_TIME (only if Pi-hole should set the host's clock over NTP); they are left out here.
      - SYS_NICE

volumes:
  pihole-etc:
.envVerified 2026-10-19 · Pi-hole v6 2026.10.0Download .env for Pi-hole v6
# Password for the Pi-hole web interface and API. Choose your own; the stack does not start without it.
# A placeholder would become a real, known password, so the lab's value is not published and this line is commented out.
# Generate one with:  openssl rand -hex 16
# PIHOLE_PASSWORD=
compose.yamlVerified 2026-10-19 · AdGuard Home v0.107.79Download compose.yaml for AdGuard Home
# Verified compose for AdGuard Home, tested 2026-10-19 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: AMD EPYC 9V45 96-Core Processor, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: adguard-home.json (schema 3).
# Lab difference: The lab run publishes no host ports (53, 80 and 3000 of the published file are not mapped): the runner's systemd-resolved listens on 127.0.0.53:53 and Docker's port 53 publishing can clash with it, and the setup and load steps reach AdGuard Home by service name over the Compose network instead. Image and volumes are the published file's. The published file has not been run with its ports mapped in the lab.

# AdGuard Home (official image adguard/adguardhome): a network-wide DNS ad blocker with a web interface.
# Follows the Docker Hub page https://hub.docker.com/r/adguard/adguardhome and the wiki
# https://github.com/AdguardTeam/AdGuardHome/wiki/Docker : two volumes, work and conf, and the port list.
# Only the ports for plain DNS and the web interface are published; add the encrypted-DNS, DHCP and DNSCrypt
# ports from the wiki if you use those features.
# AdGuard Home has no password variable: the first start serves a setup wizard on port 3000 where you choose the
# admin user and password. Do that before you expose anything.
# Do not expose port 53 to the internet: an open resolver is used for DDoS reflection.
name: adguard-home

services:
  adguardhome:
    image: adguard/adguardhome:v0.107.79  # sha256:aba9e3bf0613be3ba3755e1fc311b126e2c24bec25e18b6483894a88283074f0  (version v0.107.79, from label:org.opencontainers.image.version)
    restart: unless-stopped
    ports:
      # DNS, for the devices on your network. If the host's own DNS (systemd-resolved on Ubuntu and Debian)
      # already listens on port 53, Docker cannot bind it: see "Port 53 is already in use" in the article.
      - "53:53/tcp"
      - "53:53/udp"
      # The web interface. The setup wizard asks which port to use; 80 is its default.
      - "80:80/tcp"
      # The setup wizard, first start only. After the wizard AdGuard Home listens on the port you chose (80) and 3000 is unused.
      - "3000:3000/tcp"
    volumes:
      # Everything AdGuard Home writes: query log and statistics (work) and AdGuardHome.yaml, the settings (conf).
      - adguard-work:/opt/adguardhome/work
      - adguard-conf:/opt/adguardhome/conf

volumes:
  adguard-work:
  adguard-conf:

Each stack is one container. Pi-hole publishes 53 (TCP and UDP), 80 and 443, and keeps its databases, lists and pihole.toml in the pihole-etc volume. AdGuard Home publishes 53 (TCP and UDP), 80 and 3000, with query log, statistics and AdGuardHome.yaml in two volumes. Neither file publishes the DHCP ports. If you add DHCP, Pi-hole also needs the NET_ADMIN capability (the file’s comments say so) and AdGuard Home’s wiki points to host networking.

If you run the Caddy from our Vaultwarden Docker Compose guide on the same host, ports 80 and 443 overlap with both files by their published port lists (Caddy publishes 80, 443 and 443/udp). Change the left-hand numbers on one side. We did not run the stacks together.

Port 53 is already in use

On Ubuntu and Debian hosts systemd-resolved usually listens on 127.0.0.53:53, and Docker then cannot publish port 53. Not tested in our lab: we have not seen the clash on the runner, which is why the lab run published no ports. Both projects document the fix, and it is the same idea: switch off resolved’s stub listener, then point /etc/resolv.conf at the file resolved writes with the real upstream servers.

Pi-hole’s Docker tips name Ubuntu 17.10 and later and Fedora 33 and later, and give the three commands below.

Not run in our lab.

bash
sudo sh -c 'mkdir -p /etc/systemd/resolved.conf.d && printf "[Resolve]\nDNSStubListener=no\n" | tee /etc/systemd/resolved.conf.d/no-stub.conf'
sudo sh -c 'rm -f /etc/resolv.conf && ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf'
sudo systemctl restart systemd-resolved

AdGuard Home’s Docker wiki (which says it is outdated and points to the Knowledge Base, whose getting-started page does not cover this) uses a drop-in file named adguardhome.conf with one more line, DNS=127.0.0.1, which it says is necessary because otherwise the nameserver would be 127.0.0.53.

Not run in our lab.

ini
[Resolve]
DNS=127.0.0.1
DNSStubListener=no

Then the same resolv.conf link and a systemctl reload-or-restart systemd-resolved. Pi-hole’s page warns that fully disabling resolved can break name resolution in VPNs.

What we did not test

  • DNS-over-HTTPS, DNS-over-TLS and DNS-over-QUIC, as server or as upstream. AdGuard Home ran with plain UDP upstream.
  • DHCP in either app, and IPv6.
  • Real clients and a real household’s query mix: the load came from one Python script with one set of 900 names, and the cache-hit mix is ours.
  • DNSSEC validation (off in both), more than one blocklist, and a blocklist larger than 71.5k entries.
  • Growth of the query log or statistics over days, and RAM after days of traffic.
  • Arm hardware and the Raspberry Pi: one x86-64 runner only. The registry lists Arm images for both.
  • Both apps on the same CPU model. A clean latency comparison needs that.
  • The published files with port 53 mapped on a host that runs systemd-resolved, and the fix for it.
  • Browser use of either web interface, upgrades, and moving settings between the two.
  • Memory during Pi-hole’s first-start list build, and the lab runner’s cgroup accounting (see what the idle RAM figure includes).

Update log

  • Linked the new Uptime Kuma Docker Compose page from Related. No measurements changed.
  • Re-tested on Pi-hole 2026.10.0 (FTL v6.8), a security release with 17 advisories (was 2026.09.0, FTL v6.7.1), and re-ran AdGuard Home v0.107.79 on Oct 19, three runs each; added an update notice. Both apps loaded a newer StevenBlack list (71,493 domains and 71,506 rules, was 72,526 and 72,539), so 38 of the 300 blocklist test names are no longer blocked and blocked answers fell to 9,581 per run (was 10,975). Idle RAM: Pi-hole 9.3 MiB (unchanged), AdGuard Home 39.3 MiB (was 45.3), so the lab gap is 4.2x (was 4.9x); peak 18.2 (was 18.6) and 61.4 MiB (was 65.9). The CPU models changed and still differ (Pi-hole EPYC 9V74, was Xeon 8573C; AdGuard Home EPYC 9V45, was EPYC 7763), repeated-query latency fell by about half or more on both, and AdGuard Home's median is now the lower one on all eight repeated-query rows (was a mix), so latency is still not a head-to-head. The Oct 14 sandbox check was not repeated (it ran Pi-hole 2026.09.0). Outbound link status re-checked; the feature rows are still the Oct 14 reading. No change to the verdict.
  • First published. Pi-hole 2026.09.0 (FTL v6.7.1) and AdGuard Home v0.107.79, three runs each on Oct 13, 2026 on GitHub Actions runners, one synthetic DNS client, x86-64 only. The two apps ran in separate jobs on different CPU models, so latency is not a head-to-head. The feature comparison is researched and marked as such. A manual check of what Pi-hole's idle RAM figure includes (Oct 14, sandbox) is in lab/notes/dns-2026-10-14.md.