Pi-hole
1. What Pi-hole Is
Pi-hole is a DNS server that refuses to resolve advertising, tracking and malware domains. Every device on the network asks it for addresses; if the name is on a blocklist it answers 0.0.0.0 (or ::), so the request never leaves the device. Everything else is forwarded to an upstream resolver and cached.
Because it works at the DNS layer it blocks for every device on the network - phones, TVs, IoT devices, consoles - with nothing installed on them. That is the whole value proposition, and also the source of every limitation (section 12).
What you get beyond blocking:
| Feature | Why it matters in a home lab |
|---|---|
| Query log and dashboard | See exactly what each device is talking to |
| Local DNS records | proxmox.lan, jupyter.lan instead of remembering IPs |
| Caching | Repeat lookups answered in under 1 ms |
| Per-client groups | Different blocklists for kids, IoT, servers |
| Optional DHCP server | Replaces the router’s DHCP when it cannot hand out a custom DNS server |
| REST API (v6) | Script blocking toggles, read stats into Grafana or Home Assistant |
It is not a firewall, not a VPN, and not an ad blocker in the browser-extension sense. It cannot see URLs, only hostnames.
2. How DNS Sinkholing Works
laptop Pi-hole (FTL) upstream
| A? ads.example.com | |
|------------------------>| gravity match -> blocked |
|<------------------------| 0.0.0.0 |
| | |
| A? github.com | |
|------------------------>| cache miss |
| |------------------------------->|
| |<-------------------------------|
|<------------------------| 140.82.x.x (cached for TTL) |
The lookup path inside Pi-hole, in order:
- Exact allowlist and regex allowlist - if matched, never blocked.
- Exact denylist and regex denylist - your manual blocks.
- Gravity - the merged, de-duplicated domain set built from every subscribed list.
- Local records / cache / upstream - normal resolution.
Allow beats deny, which is why “this site broke, allow it” is the standard fix.
Blocking modes (dns.blocking.mode):
| Mode | Answer for a blocked domain | Notes |
|---|---|---|
NULL (default) |
0.0.0.0 / :: |
Fast failure, best client behaviour |
IP_NODATA_AAAA |
Pi-hole’s IP for A, NODATA for AAAA | Legacy, shows a block page if a web server listens |
IP |
Pi-hole’s IP | Same, both families |
NXDOMAIN |
“No such domain” | Some apps retry aggressively on NXDOMAIN |
NODATA |
Empty answer | Rarely needed |
Keep NULL unless something specific needs otherwise.
3. Architecture of v6
Pi-hole v6 (released February 2025) was a large rewrite. If a tutorial mentions lighttpd, PHP, setupVars.conf or pihole -a -p, it is describing v5.
| Component | v5 | v6 |
|---|---|---|
| DNS engine | pihole-FTL (embedded dnsmasq fork) |
Same |
| Web server | lighttpd + PHP | Embedded in FTL (CivetWeb), no PHP |
| API | Ad-hoc PHP endpoints | REST API at /api, session auth, documented at /api/docs |
| Config | setupVars.conf, pihole-FTL.conf, dnsmasq snippets |
One file: /etc/pihole/pihole.toml |
| HTTPS | Manual | Built in, self-signed cert generated on install |
| Lists | “Adlists” (block only) | “Lists”, which can be block or allow lists |
| Docker env vars | WEBPASSWORD, PIHOLE_DNS_ |
FTLCONF_<section>_<key> mapping straight onto the TOML |
Files that matter:
| Path | Contents |
|---|---|
/etc/pihole/pihole.toml |
All configuration. Edit via web UI, pihole-FTL --config, or by hand then restart |
/etc/pihole/gravity.db |
SQLite: lists, domains, groups, clients, and the built gravity table |
/etc/pihole/pihole-FTL.db |
SQLite: long-term query history (default 91 days) |
/etc/pihole/tls.pem |
The web UI certificate |
/var/log/pihole/pihole.log |
Live DNS log |
/var/log/pihole/FTL.log |
Daemon log, check this first when something is wrong |
One process, pihole-FTL, now does DNS, DHCP, the web UI and the API. That makes it light: an idle Pi-hole uses around 50-100 MB of RAM.
4. Deployment Options
| Option | Good for | Watch out for |
|---|---|---|
| Proxmox LXC (this page) | Home labs already running Proxmox | The host and the DNS server share fate; plan a second instance |
| Raspberry Pi | A dedicated, separate-failure-domain box | SD card wear; use an SSD or log2ram |
| Docker | Already running a Docker host | Port 53 conflicts with systemd-resolved; DHCP needs network_mode: host |
| Proxmox VM | Strict isolation | 10x the RAM of an LXC for no practical gain |
An unprivileged LXC is the right default on Proxmox: it boots in seconds, uses ~100 MB of RAM, and DNS needs no special kernel features. Use a VM only if you also want the guest to run its own Docker stack.
Sizing: 1 vCPU, 512 MB RAM, 512 MB swap, 4-8 GB disk. Gravity with a few million domains still fits. The query database is the only thing that grows; cap it with database.maxDBdays.
5. Plan the Network First
Decide these before creating anything, because changing a DNS server’s IP later means touching every client.
| Decision | Recommendation |
|---|---|
| IP address | Static, outside the router’s DHCP pool. Following a VMID = last octet convention, e.g. CT 210 at 192.168.2.210 |
| Hostname | pihole |
| Upstream | Unbound on the same CT (section 8), or a public resolver to start |
| Who hands out DNS | The router’s DHCP (option 6) pointing at Pi-hole, if the router allows it |
| Second instance | Yes, eventually, on different hardware (section 11) |
| IPv6 | Either give Pi-hole an IPv6 address and advertise it, or stop the router advertising its own (section 12) |
The bootstrap loop to avoid: the Pi-hole container itself must not use Pi-hole as its resolver. If it does, a stopped FTL means the container cannot resolve install.pi-hole.net or apt mirrors to repair itself. Give the CT a public resolver (or the router) as its own nameserver.
Likewise, think before pointing the Proxmox host at Pi-hole. If the CT is down, the host loses DNS, and apt, ACME renewals, and backups to named targets start failing. Either leave the host on the router or a public resolver, or list two resolvers on the host.
6. Create the LXC
6a. From the web UI
- Download a template: node -> local -> CT Templates -> Templates, pick
debian-12-standard(Debian 13 also works with current Pi-hole). - Create CT:
- General: CT ID
210, hostnamepihole, Unprivileged container ticked, set a root password and SSH key. - Template: the Debian template.
- Disks: 8 GB.
- CPU: 1 core. Memory: 512 MB, swap 512 MB.
- Network:
vmbr0, IPv4 Static192.168.2.210/24, gateway192.168.2.1. - DNS: domain
lan, server1.1.1.1(the bootstrap loop above).
- General: CT ID
- Options -> Start at boot: Yes, and give it a low start order (e.g.
1) so DNS is up before the guests that depend on it.
6b. From the shell (as root on the host)
pveam update
pveam download local debian-12-standard_12.7-1_amd64.tar.zst
pct create 210 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
--hostname pihole \
--unprivileged 1 \
--cores 1 --memory 512 --swap 512 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.2.210/24,gw=192.168.2.1 \
--nameserver 1.1.1.1 --searchdomain lan \
--onboot 1 --startup order=1 \
--features nesting=1 \
--ssh-public-keys /root/.ssh/authorized_keys
pct start 210
pct enter 210Run pveam available | grep debian for the exact current template filename. nesting=1 is what lets modern systemd run cleanly in an unprivileged CT.
6c. With Terraform (bpg/proxmox)
The same container as code, matching how this repo manages knowledge-lab:
resource "proxmox_virtual_environment_container" "pihole" {
node_name = "pve"
vm_id = 210
unprivileged = true
start_on_boot = true
startup { order = 1 }
initialization {
hostname = "pihole"
ip_config {
ipv4 {
address = "192.168.2.210/24"
gateway = "192.168.2.1"
}
}
dns {
servers = ["1.1.1.1"]
domain = "lan"
}
user_account { keys = [var.ssh_public_key] }
}
cpu { cores = 1 }
memory {
dedicated = 512
swap = 512
}
disk {
datastore_id = "local-lvm"
size = 8
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
operating_system {
template_file_id = "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
type = "debian"
}
features { nesting = true }
}
6d. The community helper script
community-scripts.github.io/ProxmoxVE (the successor to the tteck scripts) has a one-liner that creates the CT and installs Pi-hole in one go. It works, but it runs a remote script as root on the hypervisor. Read it first, and prefer the explicit steps above if you want to know what was configured.
7. Install Pi-hole
Inside the container:
apt update && apt full-upgrade -y
apt install -y curl
curl -sSL https://install.pi-hole.net | bashThe safer variant is to download, read, then run:
curl -sSL https://install.pi-hole.net -o basic-install.sh
less basic-install.sh
bash basic-install.shInstaller choices:
| Prompt | Answer |
|---|---|
| Static IP | Confirm the address you set on the CT |
| Upstream DNS | Anything for now (Cloudflare / Quad9); replaced by Unbound in section 8 |
| Blocklist | Keep the default StevenBlack list |
| Web admin interface | Yes |
| Query logging | Yes |
| Privacy mode | 0 (show everything) for a home lab, higher if others share the network |
Then set the admin password (v6 command; pihole -a -p is v5):
pihole setpasswordThe UI is at http://192.168.2.210/admin and https://192.168.2.210/admin (self-signed).
Test before pointing anything at it, from another machine:
dig @192.168.2.210 github.com +short # should resolve
dig @192.168.2.210 doubleclick.net +short # should be 0.0.0.0Port 53 already in use?
Debian LXC templates do not run systemd-resolved, but Ubuntu ones do, and its stub listener on 127.0.0.53:53 blocks FTL. Fix:
sed -i 's/^#\?DNSStubListener=.*/DNSStubListener=no/' /etc/systemd/resolved.conf
systemctl restart systemd-resolved
ss -lunp | grep ':53 ' # nothing but pihole-FTL should be here8. Unbound: Be Your Own Recursive Resolver
By default Pi-hole forwards to a public resolver, which then sees every name your network looks up. Unbound removes that middleman: it walks the DNS tree itself from the root servers, validates DNSSEC, and caches. No single third party sees the full query stream.
Trade-off: the first lookup of an uncached name takes 50-300 ms instead of ~20 ms. After that the cache answers. In practice it is not noticeable.
apt install -y unbound/etc/unbound/unbound.conf.d/pi-hole.conf:
server:
verbosity: 0
interface: 127.0.0.1
port: 5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
prefer-ip6: no
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
edns-buffer-size: 1232
prefetch: yes
num-threads: 1
so-rcvbuf: 1m
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: 172.16.0.0/12
private-address: 10.0.0.0/8
private-address: fd00::/8
private-address: fe80::/10
Debian’s package also ships a helper that pushes Unbound’s resolvers into resolvconf, which defeats the point. Disable it:
systemctl disable --now unbound-resolvconf.service 2>/dev/null
rm -f /etc/unbound/unbound.conf.d/resolvconf_resolvers.conf
systemctl restart unbound
dig pi-hole.net @127.0.0.1 -p 5335 # NOERROR
dig fail01.dnssec.works @127.0.0.1 -p 5335 # SERVFAIL (DNSSEC working)
dig dnssec.works @127.0.0.1 -p 5335 | grep flags # has the 'ad' flagThen point Pi-hole at it and drop every other upstream:
pihole-FTL --config dns.upstreams '["127.0.0.1#5335"]'Or in the UI: Settings -> DNS, untick all, add custom 127.0.0.1#5335. Do not also enable Pi-hole’s own DNSSEC option; Unbound already validates and doing it twice only adds noise.
Alternatives to full recursion: DNS-over-HTTPS via cloudflared as the upstream (encrypts the hop to Cloudflare, but Cloudflare still sees everything), or Unbound configured as a DNS-over-TLS forwarder to Quad9. Pick recursion for privacy from resolvers, DoT/DoH forwarding for privacy from your ISP.
9. Point the Network at It
Option A: Router DHCP (preferred)
In the router’s LAN/DHCP settings, set DNS server 1 = 192.168.2.210 and leave DNS server 2 empty, or set it to a second Pi-hole.
Never set a public resolver as the secondary. Clients do not treat the second server as a fallback; they use whichever answers, so a good share of lookups bypass blocking entirely and it looks like Pi-hole “sometimes works”.
Renew a lease to pick it up (sudo dhclient -r && sudo dhclient, or toggle Wi-Fi), then check:
resolvectl status | grep 'DNS Servers' # Linux
nslookup doubleclick.net # anywhereOption B: Pi-hole as the DHCP server
Many ISP routers will not let you change the DNS they hand out. Then turn the router’s DHCP off and Pi-hole’s on (Settings -> DHCP): range, gateway (the router), lease time. Pi-hole will then advertise itself as DNS, and as a bonus its query log shows hostnames instead of bare IPs.
Do not run both DHCP servers at once. In an LXC, DHCP works fine on a normal bridged interface; in Docker it needs network_mode: host.
Option C: Router as DNS forwarder
Setting the Pi-hole as the router’s WAN-side upstream DNS works, but every query then appears to come from the router’s IP, so per-client stats and groups are useless and the per-client rate limit (section 12) trips. Only use this if A and B are impossible.
Static hosts
Servers with static IPs (including Proxmox guests) take DNS from their own config, not DHCP. Set them explicitly, or leave infrastructure on the router and only filter clients.
10. Day-to-Day Configuration
Lists
Lists in the UI, or the adlist table in gravity.db. Add list URLs, then rebuild:
pihole -g # "update gravity": download every list and rebuild the tableGravity also refreshes weekly via a cron job the installer creates. More lists is not better: overlapping huge lists cause false positives faster than they add coverage. A sensible set is the default StevenBlack list plus one of the curated multi-source lists (e.g. HaGeZi “Multi PRO” or OISD “big”). v6 can also subscribe to allow lists, which is the clean way to share a “known good” set.
Allow and deny
pihole allow example.com # exact allow (v5 was 'pihole -w')
pihole deny tracker.example.com # exact deny (v5 was 'pihole -b')
pihole --regex '(^|\.)tiktok\.com$' # regex deny
pihole --allow-regex '^cdn\.example\.com$' # regex allowLocal DNS records
The home lab feature that pays for itself. Settings -> Local DNS Records, or in pihole.toml:
[dns]
hosts = [
"192.168.2.70 proxmox.lan",
"192.168.2.205 jupyter.lan",
"192.168.2.210 pihole.lan"
]
cnameRecords = [
"grafana.lan,proxmox.lan"
]pihole-FTL --config dns.hosts '["192.168.2.70 proxmox.lan", "192.168.2.210 pihole.lan"]'Avoid .local (reserved for mDNS). Use .lan, .home.arpa, or a subdomain of a domain you own (lab.example.com), which also lets you get real TLS certificates via DNS-01 challenges.
Conditional forwarding
When the router does DHCP, it knows client hostnames and Pi-hole does not. Conditional forwarding sends reverse lookups for the LAN back to the router so the query log shows names:
[dns]
revServers = [ "true,192.168.2.0/24,192.168.2.1,lan" ]Groups and clients
Groups attach lists and allow/deny rules to sets of clients. Typical layout: Default (everyone), Kids (adds a strict list), IoT (adds telemetry lists), Unfiltered (servers that should never be blocked). A client is identified by IP, MAC, hostname, or interface, so groups work best with DHCP reservations.
Temporarily disable
pihole disable 5m
pihole enable11. Redundancy
One Pi-hole is a single point of failure for the whole network’s DNS, and on Proxmox it shares fate with the host. A reboot for a kernel update takes the internet down for everyone.
| Approach | How | Effort |
|---|---|---|
| Two instances, two DNS entries | Second Pi-hole on different hardware (a Raspberry Pi, or another node), both handed out by DHCP | Low. Clients split queries between them; that is fine because both filter |
| Keep them in sync | nebula-sync (the v6-era tool; gravity-sync is v5 only and archived) copies lists, groups, local DNS and settings from a primary to replicas via the API |
Low, one container or binary on a schedule |
| Shared virtual IP | keepalived with VRRP on both, clients get a single VIP |
Medium; nicer failover, one IP to hand out |
| Proxmox HA | Only meaningful with a cluster and shared storage | High; overkill for DNS |
Minimum viable: CT on Proxmox as primary, Pi-hole on a Pi as secondary, nebula-sync hourly, both IPs in DHCP.
Backup
- Teleporter (Settings -> Teleporter, or
pihole-FTL --teleporter) exports a zip of config, lists, groups and local records. Restore on a new install in seconds. vzdumpthe CT with the rest of your Proxmox backups. A 512 MB container backs up in a few seconds.pihole.toml+gravity.dbare the two files that define the instance.
12. Limitations and Gotchas
What DNS blocking cannot do:
| Case | Why |
|---|---|
| YouTube / Twitch video ads | Served from the same domains as the video. Blocking them blocks the video |
| Facebook / Instagram sponsored posts | Same domains as the content |
| Cosmetic filtering (empty ad boxes) | Pi-hole never sees the page. Use uBlock Origin alongside |
| Apps with hardcoded DNS | Chromecast and some TVs query 8.8.8.8 directly |
| Browser DNS-over-HTTPS | The browser bypasses the system resolver entirely |
Countermeasures for bypass:
- Hardcoded DNS: on the router/firewall, NAT-redirect outbound port 53 (UDP and TCP) from every LAN host except the Pi-hole to the Pi-hole. On OPNsense/pfSense this is a port-forward rule; on OpenWrt a firewall redirect. Block outbound 853 (DNS-over-TLS) too.
- Browser DoH: Pi-hole answers the Firefox canary domain
use-application-dns.netwith NXDOMAIN by default, which makes Firefox disable automatic DoH. v6 also blocks Apple’s iCloud Private Relay bootstrap domains by default (dns.specialDomains). Chrome only upgrades to DoH when the configured resolver supports it, so it stays on Pi-hole. A user who manually enables DoH with a fixed provider still bypasses it.
Operational gotchas:
- IPv6 leakage. If the router advertises its own IPv6 DNS server via RA/RDNSS or DHCPv6, clients will prefer it and skip Pi-hole. Either give Pi-hole a stable IPv6 (ULA) address and advertise that, or turn off the router’s IPv6 DNS advertisement.
- Rate limiting. FTL limits each client to 1000 queries per 60 s by default (
dns.rateLimit). Option C in section 9 makes the router one client for the whole network and trips it. Raise the limit or fix the topology. - Listening mode. The default
dns.listeningMode = "LOCAL"only answers clients on directly connected subnets. Clients reaching it over a WireGuard tunnel or from another VLAN get refused. Set"ALL"(or"BIND"to specific interfaces) once the firewall guarantees only trusted networks can reach port 53. - Never expose port 53 to the internet. An open resolver gets found within hours and used for DNS amplification attacks. For DNS while away, connect home over WireGuard and use Pi-hole through the tunnel (set the WireGuard peer’s
DNS = 192.168.2.210). - Bootstrap loop. Restated because it is the most common self-inflicted outage: the Pi-hole CT and the Proxmox host should not depend solely on Pi-hole for their own DNS.
- Something broke. Open Query Log, filter by the client, reproduce, look for red “blocked” rows around that time, allow the domain. Most breakage is a CDN or analytics domain an app treats as essential.
13. Maintenance and CLI Reference
pihole status # is DNS listening and blocking enabled
pihole -up # update Pi-hole core, web and FTL
pihole -g # rebuild gravity
pihole -t # tail the live query log
pihole -q example.com # which list(s) contain this domain
pihole setpassword # change the admin password
pihole reloaddns # reload lists without restarting
pihole restartdns # full FTL restart
pihole -d # debug log, optionally uploaded for support
pihole-FTL --config # dump the whole config
pihole-FTL --config dns.upstreams # read one keyUpdate the OS as normal (apt full-upgrade) and Pi-hole with pihole -up; they are independent. With two instances, update one at a time.
14. The v6 REST API
Everything in the UI goes through /api, with interactive docs at http://<pihole>/api/docs. Auth is a session: POST the password, get a sid, send it on each call. For scripts, generate an app password (Settings -> Web interface / API -> Configure app password) so the real admin password is never in a script.
import os
import requests
BASE = "http://192.168.2.210/api"
def session(password: str) -> str:
r = requests.post(f"{BASE}/auth", json={"password": password}, timeout=5)
r.raise_for_status()
return r.json()["session"]["sid"]
sid = session(os.environ["PIHOLE_APP_PASSWORD"])
h = {"X-FTL-SID": sid}
summary = requests.get(f"{BASE}/stats/summary", headers=h, timeout=5).json()
print(summary["queries"]["total"], summary["queries"]["percent_blocked"])
# disable blocking for 5 minutes
requests.post(f"{BASE}/dns/blocking", headers=h,
json={"blocking": False, "timer": 300}, timeout=5)
requests.delete(f"{BASE}/auth", headers=h, timeout=5) # end the sessionClose sessions when done: FTL keeps a small fixed number of concurrent sessions (webserver.api.max_sessions), and a cron script that never logs out will eventually lock the UI out.
The Home Assistant Pi-hole integration and the Grafana/Prometheus exporters all sit on this API.
15. Running It in Docker Instead
For completeness, the v6 container. Note the FTLCONF_ variables map directly onto pihole.toml keys, with . replaced by _.
services:
pihole:
image: pihole/pihole:latest
container_name: pihole
ports:
- "53:53/tcp"
- "53:53/udp"
- "80:80/tcp"
- "443:443/tcp"
# - "67:67/udp" # only with network_mode: host for DHCP
environment:
TZ: "Australia/Brisbane"
FTLCONF_webserver_api_password: "${PIHOLE_PASSWORD}"
FTLCONF_dns_upstreams: "1.1.1.1;9.9.9.9"
FTLCONF_dns_listeningMode: "all"
volumes:
- ./etc-pihole:/etc/pihole
cap_add:
- SYS_NICE
restart: unless-stoppedlisteningMode: all is needed because Docker’s bridge NAT makes every client appear to come from a non-local subnet. On a Docker host running Ubuntu, disable the systemd-resolved stub listener first (section 7).
16. Setup Checklist
- Pick a static IP outside the DHCP pool and a CT ID.
- Create an unprivileged Debian LXC: 1 core, 512 MB, 8 GB, start at boot with order 1, nameserver set to a public resolver.
curl -sSL https://install.pi-hole.net | bash, thenpihole setpassword.dig @<ip> doubleclick.netreturns0.0.0.0from another machine.- Install Unbound on port 5335, verify DNSSEC, set it as the only upstream.
- Add local DNS records for the lab; enable conditional forwarding if the router does DHCP.
- Point the router’s DHCP DNS at Pi-hole, with no public secondary. Handle IPv6 DNS advertisement.
- Optionally redirect outbound port 53 to Pi-hole on the router.
- Add a second instance and
nebula-sync; add the CT tovzdumpbackups; export a Teleporter zip.