Skip to content

Pi-hole as Local DNS for the Homelab

This guide walks through running Pi-hole in Docker on an always-on server so the homelab can reach services by hostname (e.g. harbor.dev.lan, colony.dev.lan) instead of by IP and port.

Pi-hole acts as the LAN's DNS server: every device on the network asks it to resolve names, and we configure it with a wildcard record that points *.dev.lan at the k3s ingress IP. Traefik (built into k3s) then routes requests by hostname.

As a bonus, Pi-hole filters ads and trackers at the DNS level for every device on the network.

Overview

flowchart LR
    Client[Laptop / Phone] -->|DNS query: harbor.dev.lan| PiHole[Pi-hole<br/>always-on server]
    PiHole -->|*.dev.lan rewrite| K3sIP[k3s node IP]
    Client -->|HTTP request<br/>Host: harbor.dev.lan| Traefik[Traefik Ingress]
    Traefik --> Harbor[Harbor pod]
    PiHole -->|public domains| Upstream[1.1.1.1 / 9.9.9.9]

Prerequisites

  • An always-on server on the LAN (separate from the k3s nodes recommended) with a static IP.
  • Docker and Docker Compose installed on that server.
  • Admin access to the home router (to change the DHCP DNS server setting).
  • A planned hostname suffix for internal services. This guide uses dev.lan. Do not use .local — it conflicts with mDNS/Bonjour and causes intermittent resolution failures.

Step 1: Free Port 53 on the Host

Bare-metal Pi-hole? Skip to Step 3

Steps 1–2 only apply to running Pi-hole in Docker. If Pi-hole runs on a dedicated box installed directly on the OS (Raspberry Pi OS image, or curl -sSL https://install.pi-hole.net | bash), the installer already frees port 53 and runs Pi-hole as a system service. Go straight to Step 3 and use the bare-metal path.

On Ubuntu/Debian hosts, systemd-resolved listens on 127.0.0.53:53 and will conflict with Pi-hole. Disable its stub listener:

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/disable-stub-listener.conf > /dev/null <<'EOF'
[Resolve]
DNSStubListener=no
EOF

# Replace the symlinked resolv.conf so the host can still resolve names while Pi-hole boots
sudo rm /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf

sudo systemctl restart systemd-resolved

Verify nothing is listening on port 53:

sudo ss -tulpn | grep :53
# Expect no output

Step 2: Create the Pi-hole Compose File

Pick a directory for persistent data, e.g. /opt/pihole:

sudo mkdir -p /opt/pihole/{etc-pihole,etc-dnsmasq.d}
cd /opt/pihole

Create docker-compose.yml:

services:
  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    # Host networking is the simplest path — Pi-hole binds directly to the LAN interface,
    # no port mapping gymnastics, and clients see the real source IP in query logs.
    network_mode: host
    environment:
      TZ: 'America/Mexico_City'           # set to your timezone
      WEBPASSWORD: 'CHANGE-ME-STRONG-PW'  # admin UI password
      PIHOLE_DNS_: '1.1.1.1;9.9.9.9'      # upstream resolvers
      DNSMASQ_LISTENING: 'local'          # only answer queries from the LAN
      VIRTUAL_HOST: 'pihole.dev.lan'      # admin UI hostname
    volumes:
      - ./etc-pihole:/etc/pihole
      - ./etc-dnsmasq.d:/etc/dnsmasq.d
    cap_add:
      - NET_ADMIN
    restart: unless-stopped

Bind to the LAN interface only

DNSMASQ_LISTENING: local tells Pi-hole to refuse queries that arrive from outside its subnet. Combined with never port-forwarding port 53 on the router, this prevents the server from being abused as an open resolver in DNS amplification attacks.

Bring it up:

sudo docker compose up -d
sudo docker compose logs -f pihole   # watch the first boot

Step 3: Add the Wildcard DNS Record

This is the rule that makes harbor.dev.lan, colony.dev.lan, and any other *.dev.lan host resolve to the k3s ingress IP.

First, find the IP that Traefik is listening on:

kubectl get svc -n kube-system traefik

Look at the EXTERNAL-IP column. In this homelab the Traefik service is exposed at 192.168.1.206 and 192.168.1.208 (k3s's built-in service load balancer publishes one IP per node). Either IP works — pick one and use it consistently.

Pi-hole's web UI (Local DNS → DNS Records) only stores exact A records — it has no wildcard support. The wildcard must live in a dnsmasq custom config file. The contents are identical regardless of how Pi-hole is installed:

# 02-homelab.conf
# Wildcard: every *.dev.lan name (and any sub-subdomain like api.colony.dev.lan)
# resolves to the Traefik ingress IP.
address=/dev.lan/192.168.1.206

dnsmasq wildcards match all sub-levels

address=/dev.lan/... matches harbor.dev.lan, colony.dev.lan, AND api.colony.dev.lan, foo.bar.dev.lan, etc. One line covers every internal hostname, so adding a new app never needs a Pi-hole change — you only create its Ingress (Step 5).

Where the file goes and how to reload depends on the install:

Docker install:

sudo tee /opt/pihole/etc-dnsmasq.d/02-homelab.conf > /dev/null <<'EOF'
address=/dev.lan/192.168.1.206
EOF
sudo docker exec pihole pihole restartdns

Bare-metal install (Raspberry Pi OS / direct install):

sudo tee /etc/dnsmasq.d/02-homelab.conf > /dev/null <<'EOF'
address=/dev.lan/192.168.1.206
EOF
sudo pihole restartdns

Pi-hole v6: enable custom dnsmasq files

Pi-hole v6 (the current release) ignores /etc/dnsmasq.d/ custom files by default. Turn the setting on once, then reload:

sudo pihole-FTL --config misc.etc_dnsmasq_d true
sudo pihole restartdns

The same toggle lives in the web UI under Settings → All settings → misc.etc_dnsmasq_d. (Docker: set FTLCONF_misc_etc_dnsmasq_d: 'true' in the compose environment: instead.) Skipping this is the most common reason the wildcard "does nothing."

Test from another machine on the LAN:

dig @<pihole-ip> harbor.dev.lan +short
# Expect: 192.168.1.206

Step 4: Point the LAN at Pi-hole

Tell devices on the network to use Pi-hole for DNS. Two ways: network-wide at the router (best — every device, zero per-machine config), or per-machine when the router won't let you change DNS.

  1. Log in to the home router admin UI.
  2. Find DHCP settings (often under LAN or Network).
  3. Set Primary DNS to the Pi-hole server's static IP.
  4. Set Secondary DNS to a public resolver (1.1.1.1 or 9.9.9.9) — see the trade-off note below.
  5. Save and either restart the router or wait for DHCP leases to renew (usually under an hour).

Secondary DNS trade-off

A public secondary keeps the internet working when Pi-hole is down, but clients will silently fall back and *.dev.lan won't resolve. Two options:

  • Pragmatic: keep the public secondary. Accept that Pi-hole going down breaks the internal hostnames temporarily.
  • Strict: leave the secondary blank. Internal names always work, but a Pi-hole outage breaks DNS for the whole household until it's restored.

Per-machine: point one Linux client at Pi-hole

Use this when the router's DNS field is locked (common on ISP-supplied gateways — an attlocal.net search domain is a tell-tale AT&T sign) or when only one machine should use Pi-hole. On modern Ubuntu/Debian desktops, NetworkManager owns the connection and systemd-resolved does the resolving. Set DNS on the NetworkManager connection — not by editing /etc/resolv.conf, which is an auto-managed symlink whose edits won't survive.

# Find the active connection name and your interface
nmcli -t -f NAME,DEVICE connection show --active

# Point that connection at Pi-hole (replace the name and the Pi-hole's static IP)
sudo nmcli connection modify "<connection-name>" ipv4.dns "192.168.1.x"
sudo nmcli connection modify "<connection-name>" ipv4.ignore-auto-dns yes
sudo nmcli connection up   "<connection-name>"

ipv4.ignore-auto-dns yes stops the router from also injecting its own resolver, which would let *.dev.lan queries leak past Pi-hole and fail intermittently. To try it before committing:

# Temporary — reverts on reconnect/reboot
sudo resolvectl dns <interface> 192.168.1.x

Confirm the machine is actually using Pi-hole (and nothing else):

resolvectl status        # "DNS Servers:" should list only the Pi-hole IP
dig harbor.dev.lan +short # → 192.168.1.206

IPv6

If your network hands out IPv6, clients may prefer an IPv6 resolver and bypass Pi-hole. Either also set ipv6.dns on the connection (the Pi-hole's IPv6 address) or disable IPv6 DNS advertisement on the router.

Force a renewal on the device you're testing from, then check:

# macOS
scutil --dns | grep nameserver

# Linux
resolvectl status

nslookup harbor.dev.lan

Step 5: Expose a k8s Application via Ingress

With DNS in place, the last piece is an Ingress resource per application that maps a hostname to a Service inside the cluster. Traefik (built into k3s) reads these and starts routing.

The general pattern:

  1. Identify the Service name, namespace, and port you want to expose.
  2. Choose a hostname under your wildcard suffix (e.g. <app>.dev.lan).
  3. Apply an Ingress that routes that hostname to the Service.
  4. Browse to http://<app>.dev.lan.

The two examples below show this pattern at two levels of complexity: a single-service API, then a frontend + backend application.

Simple Example: fast-api-docker in test-namespace

This is the smallest possible case — one Deployment, one Service, one hostname. Inspect what's running:

kubectl get svc -n test-namespace
NAME                      TYPE       CLUSTER-IP    PORT(S)
fast-api-docker-service   NodePort   10.43.56.94   80:30470/TCP

The Service listens on port 80 internally (the 80:30470 means "container port 80, exposed as NodePort 30470"). The Ingress will target port 80, the NodePort becomes irrelevant once the hostname is in place.

Pick a hostnamefast-api.dev.lan is descriptive and already covered by the *.dev.lan wildcard, so no Pi-hole changes are needed.

Create the Ingress as fast-api-ingress.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: fast-api
  namespace: test-namespace
spec:
  ingressClassName: traefik
  rules:
    - host: fast-api.dev.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: fast-api-docker-service
                port:
                  number: 80

Apply and verify:

kubectl apply -f fast-api-ingress.yaml

kubectl get ingress -n test-namespace
# NAME       CLASS     HOSTS              ADDRESS                       PORTS
# fast-api   traefik   fast-api.dev.lan   192.168.1.206,192.168.1.208   80

curl -I http://fast-api.dev.lan
# HTTP/1.1 200 OK   (or 404 / docs response, depending on the API's root route)

# FastAPI auto-generated docs:
open http://fast-api.dev.lan/docs

That's the entire flow for a single-service app. Once this works, multi-service apps are just the same thing repeated.

Advanced Example: colony-dev (Frontend + Backend)

The colony-dev namespace runs three services:

kubectl get svc -n colony-dev
NAME                    TYPE        CLUSTER-IP      PORT(S)
colony-dev-frontend     NodePort    10.43.129.82    3000:30081/TCP
colony-dev-backend      NodePort    10.43.88.200    8000:30801/TCP
colony-dev-postgresql   ClusterIP   10.43.139.235   5432/TCP

Today these are reached via NodePort (http://<node-ip>:30081, http://<node-ip>:30801). After this step you'll reach them by name instead.

Do not expose internal services

Only expose what end users need: frontend and backend. The PostgreSQL service is ClusterIP-only by design — it must stay reachable only from inside the cluster. Never create an Ingress for a database.

Choose hostnames. Two clean options:

Pattern Frontend Backend Notes
Subdomain (recommended) colony.dev.lan api.colony.dev.lan Cleanest. Frontend and backend are independently routed; no path-rewrite headaches; CORS stays simple.
Path-based colony.dev.lan/ colony.dev.lan/api Single hostname, but you'll likely need path stripping middleware so the backend doesn't see /api in its routes.

This guide uses the subdomain pattern. Both hostnames are covered by the existing *.dev.lan wildcard, so no Pi-hole changes are needed when adding new apps — you only edit Pi-hole when you add a brand-new TLD or want a record outside the wildcard.

Create the Ingress manifest. Save as colony-dev-ingress.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: colony-dev-frontend
  namespace: colony-dev
spec:
  ingressClassName: traefik
  rules:
    - host: colony.dev.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: colony-dev-frontend
                port:
                  number: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: colony-dev-backend
  namespace: colony-dev
spec:
  ingressClassName: traefik
  rules:
    - host: api.colony.dev.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: colony-dev-backend
                port:
                  number: 8000

A few notes on the manifest:

  • namespace: colony-dev — Ingresses must live in the same namespace as the Service they target.
  • port.number: 3000 / 8000 — these are the Service ports, not the NodePort ports. Look at the first number in the PORT(S) column of kubectl get svc.
  • ingressClassName: traefik — matches the IngressClass already installed in your cluster (verify with kubectl get ingressclass).
  • The Services can stay NodePort or be changed to ClusterIP. Either works for ingress routing. ClusterIP is slightly tidier since the NodePort fallback is no longer needed.

Apply and verify:

kubectl apply -f colony-dev-ingress.yaml

kubectl get ingress -n colony-dev
# NAME                  CLASS     HOSTS                   ADDRESS                           PORTS
# colony-dev-frontend   traefik   colony.dev.lan          192.168.1.206,192.168.1.208       80
# colony-dev-backend    traefik   api.colony.dev.lan      192.168.1.206,192.168.1.208       80

From a LAN client (the Pi-hole change must already be live):

dig colony.dev.lan +short          # → 192.168.1.206
dig api.colony.dev.lan +short      # → 192.168.1.206
curl -I http://colony.dev.lan      # → HTTP 200 from the frontend
curl -I http://api.colony.dev.lan  # → HTTP 200/404 from the backend (depends on root route)

Open http://colony.dev.lan in a browser. If the frontend talks to the backend, point its API base URL to http://api.colony.dev.lan (whatever env var or config drives that — typically a VITE_API_URL, NEXT_PUBLIC_API_URL, or similar in the frontend Deployment).

Adding More Apps

Repeat the pattern. To expose Harbor, for example:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: harbor
  namespace: harbor
spec:
  ingressClassName: traefik
  rules:
    - host: harbor.dev.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: harbor          # check the actual Service name with `kubectl get svc -n harbor`
                port:
                  number: 80

No Pi-hole changes needed — the wildcard already covers harbor.dev.lan.

Security Hardening

Risk Mitigation
Open resolver abuse (DNS amplification) DNSMASQ_LISTENING: local set above. Never port-forward 53/tcp or 53/udp on the router.
Admin UI compromise Strong WEBPASSWORD; access only over LAN. Do not expose /admin externally.
Query log privacy Pi-hole logs every DNS query by default. Reduce retention in Settings → Privacy, or set the privacy level to "Anonymous logging" if other people share the network.
Single point of failure Public secondary DNS at the router (covered above), or run a second Pi-hole and sync with gravity-sync.
Stale upstream / poisoning Use trustworthy upstreams (Cloudflare 1.1.1.1, Quad9 9.9.9.9); enable DNSSEC under Settings → DNS if upstreams support it.

Maintenance

Update Pi-hole:

cd /opt/pihole
sudo docker compose pull
sudo docker compose up -d

Back up settings.

The etc-pihole/ and etc-dnsmasq.d/ directories contain everything. A periodic copy to a different host (or to a backup folder on the k3s cluster) is enough to restore the full configuration.

Add another internal hostname.

Either rely on the wildcard (just create the Ingress — no DNS change needed), or add a specific record to the 02-homelab.conf from Step 3, then reload (sudo pihole restartdns, or sudo docker exec pihole pihole restartdns for Docker).

A specific record is needed when a host must point somewhere other than the ingress IP — e.g. the Rancher management UI on its own node. dnsmasq matches the most specific entry, so a narrower line overrides the wildcard:

address=/dev.lan/192.168.1.206          # everything → Traefik ingress
address=/rancher.dev.lan/192.168.1.233  # this one → Rancher's node, overrides the wildcard

Avoid the .local TLD

Use rancher.dev.lan, not rancher.local. .local is reserved for mDNS/Bonjour and causes intermittent resolution failures on machines running Avahi (most Linux desktops, all macOS). Keeping every internal name under the single dev.lan suffix also means the wildcard covers it by default.

Troubleshooting

docker compose up fails with "address already in use" on port 53. systemd-resolved is still bound. Re-run Step 1 and verify with sudo ss -tulpn | grep :53.

Clients still resolve harbor.dev.lan to NXDOMAIN. The device is using cached DNS or a different resolver. Renew the DHCP lease, then check resolvectl status (Linux) or scutil --dns (macOS) to confirm the device is actually using Pi-hole.

Pi-hole resolves the name but the browser shows a Traefik 404. DNS is working; the Ingress is wrong. Check kubectl get ingress -A and confirm the host: field matches exactly, and that the backing Service exists in the same namespace.

Pi-hole admin UI is unreachable. With host networking the UI is on http://<pihole-ip>/admin, port 80. Confirm nothing else on the host is using port 80, and check docker logs pihole.

Next Steps

  • HTTPS for internal services. Either install mkcert on every client device for self-signed certs on *.dev.lan, or buy a real domain (~$10/yr) and use cert-manager with Let's Encrypt's DNS-01 challenge. The Ingress YAML stays the same — you just add a tls: block referencing a Secret that cert-manager populates.
  • Single ingress IP. k3s's built-in service load balancer publishes Traefik on every node IP (here 192.168.1.206 and 192.168.1.208). If you want a single floating IP that survives a node going offline, swap servicelb for MetalLB in L2 mode and update the Pi-hole wildcard to point at that IP.