Skip to content

Pi-hole

Pi-hole provides DNS with network-wide ad blocking for the homelab. This page documents the in-cluster Pi-hole, which runs in k3s with a dedicated IP (192.168.1.21) assigned by kube-vip.

Role: secondary resolver (DNS2). This 192.168.1.21 instance is the in-cluster secondary to the primary hardware Pi-hole at 192.168.1.60. It is kept in sync (blocklists and local *.local.spaelling.xyz records) by a per-pod nebula-sync sidecar — see DNS redundancy for the architecture and full apply steps. Before adding it to DHCP, make sure the sync is working so it is a true equivalent of the primary.

The deployment uses the pihole-kubernetes classic manifests.

Install

Apply the kube-vip ConfigMap so Pi-hole gets its dedicated IP, then create the namespace and deploy:

kubectl apply -f kubevip-configmap.yaml
kubectl create namespace pihole
# One shared web/API password: generated once, used by both secrets so the
# nebula-sync sidecar can always authenticate against its local Pi-hole.
# (-base64 24 stays on one line; larger values wrap and embed a newline.)
PIHOLE_WEBPASSWORD=$(openssl rand -base64 24)
kubectl create secret -n pihole generic pihole-webpassword --from-literal="password=$PIHOLE_WEBPASSWORD"
# nebula-sync credentials (primary app password + localhost replica password):
kubectl create secret -n pihole generic nebula-sync-credentials --from-literal="primary=http://192.168.1.60|<PRIMARY_APP_PASSWORD>" --from-literal="replicas=http://localhost|$PIHOLE_WEBPASSWORD"
kubectl apply -f pihole.yaml

kubevip-configmap.yaml is in the k3s/kubevip directory. The generated password is stored as a Kubernetes secret — retrieve it later with kubectl get secret -n pihole pihole-webpassword -o jsonpath='{.data.password}' | base64 --decode. The nebula-sync-credentials secret and the sync design are covered in DNS redundancy.

Verify DNS is working

Once pods are running, test that Pi-hole is answering DNS queries on its dedicated IP:

tcping -f 4 -t 5 192.168.1.21 53
nslookup google.com 192.168.1.21

Both should succeed. If nslookup returns an answer from 192.168.1.21, Pi-hole is operational.

Expose the admin UI

Apply the ingress for the primary Pi-hole instance:

kubectl apply -f pihole-0-ingress.yaml

This creates a Traefik IngressRoute for the Pi-hole web UI.

Check logs

# Latest logs from the primary instance
kubectl logs pihole-0 -n pihole

# Stream logs in real time
kubectl logs -f pihole-0 -n pihole

# Logs from all replicas simultaneously
kubectl logs -l app=pihole -n pihole --all-containers

# DNS query log (inside the pod)
kubectl exec -it pihole-0 -n pihole -- tail -f /var/log/pihole/pihole.log

Check rollout status

After making changes to the StatefulSet (e.g. updating the image or config), monitor the rollout:

kubectl rollout status statefulset pihole -n pihole

Troubleshooting: ads getting through (empty gravity)

If ads start appearing network-wide, the usual cause is not that Pi-hole has become too weak but that its blocklists have been wiped: the adlist set is empty, so the compiled blocklist (Pi-hole calls it gravity) has zero domains and there is nothing to block against. This happened on 2026-09-13 when a gravity database auto-restore on the primary came back with no adlists; blocking was still switched on, but only a few percent of queries were blocked and those were mostly iCloud Private Relay (mask.icloud.com), not ads.

Confirm it. In the admin UI the dashboard shows Domains on Adlists at or near 0, and Settings -> Lists is empty. Over the API, GET /api/info/ftl reports database.gravity: 0 and GET /api/lists returns an empty list.

Local DNS records are safe, so do not reach for a full restore. The local *.local.spaelling.xyz hosts and CNAMEs live in Pi-hole's DNS config, entirely separate from gravity, and survive an empty-gravity event untouched. A full older-version Teleporter restore would overwrite the whole config, including those local records, with whatever smaller set the backup happened to hold, so it can lose current local records while fixing the blocklist. Restore the blocklist on its own instead; it never touches local DNS.

Fix via the admin UI (simplest). Settings -> Lists, add the default blocklist https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts as a block list, then Tools -> Update Gravity. Gravity rebuilds to tens of thousands of domains and blocking resumes.

Fix via the API (scripted). First authenticate to get a session id, then send it as the X-FTL-SID header on each call. The add-list call takes type as a query parameter, not a body field; passing it only in the body fails with bad_request: Specify type parameter:

# Authenticate with the instance's app password; capture the session id.
SID=$(curl -sk -X POST "http://192.168.1.60/api/auth" \
  -H 'Content-Type: application/json' \
  -d '{"password":"<pihole app password>"}' | jq -r '.session.sid')

# Add the default blocklist (type is a QUERY parameter).
curl -sk -H "X-FTL-SID: $SID" -X POST \
  "http://192.168.1.60/api/lists?type=block" \
  -H 'Content-Type: application/json' \
  -d '{"address":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","comment":"Pi-hole default blocklist","groups":[0],"enabled":true}'

# Rebuild gravity from the adlists.
curl -sk -H "X-FTL-SID: $SID" -X POST "http://192.168.1.60/api/action/gravity"

Verify. GET /api/info/ftl should show a non-zero database.gravity, and a live query is the real proof: nslookup doubleclick.net 192.168.1.60 returns 0.0.0.0 (blocked) while nslookup k3s.local.spaelling.xyz 192.168.1.60 still returns the real address (local DNS intact).

The cluster replicas heal themselves. Fix the primary (192.168.1.60) only. The per-pod nebula-sync sidecar pulls the primary's adlists and reruns gravity on each in-cluster replica on its 15-minute cycle (CRON is */15 * * * *), so DNS2 starts blocking within a quarter-hour without any manual step. See DNS redundancy.