Pi-hole

A network-wide DNS sinkhole: what it is, how v6 works, and how to run it in a Proxmox LXC with Unbound, local DNS, and a second instance for redundancy.
Author

Benedict Thekkel

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:

  1. Exact allowlist and regex allowlist - if matched, never blocked.
  2. Exact denylist and regex denylist - your manual blocks.
  3. Gravity - the merged, de-duplicated domain set built from every subscribed list.
  4. 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

  1. Download a template: node -> local -> CT Templates -> Templates, pick debian-12-standard (Debian 13 also works with current Pi-hole).
  2. Create CT:
    • General: CT ID 210, hostname pihole, 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 Static 192.168.2.210/24, gateway 192.168.2.1.
    • DNS: domain lan, server 1.1.1.1 (the bootstrap loop above).
  3. 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 210

Run 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 | bash

The 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.sh

Installer 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 setpassword

The 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.0

Port 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 here

8. 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' flag

Then 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                    # anywhere

Option 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 table

Gravity 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 allow

Local 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 enable

11. 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.
  • vzdump the CT with the rest of your Proxmox backups. A 512 MB container backs up in a few seconds.
  • pihole.toml + gravity.db are 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.net with 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 key

Update 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 session

Close 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-stopped

listeningMode: 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

  1. Pick a static IP outside the DHCP pool and a CT ID.
  2. Create an unprivileged Debian LXC: 1 core, 512 MB, 8 GB, start at boot with order 1, nameserver set to a public resolver.
  3. curl -sSL https://install.pi-hole.net | bash, then pihole setpassword.
  4. dig @<ip> doubleclick.net returns 0.0.0.0 from another machine.
  5. Install Unbound on port 5335, verify DNSSEC, set it as the only upstream.
  6. Add local DNS records for the lab; enable conditional forwarding if the router does DHCP.
  7. Point the router’s DHCP DNS at Pi-hole, with no public secondary. Handle IPv6 DNS advertisement.
  8. Optionally redirect outbound port 53 to Pi-hole on the router.
  9. Add a second instance and nebula-sync; add the CT to vzdump backups; export a Teleporter zip.

Back to top