Skip to main content
· Homelab · 4 min read

Homelab Infrastructure as Code: Pi-hole + DNS + VPN on k3s

After my Raspberry Pi running Pi-hole became unreliable — an SD card corruption, forgotten crontabs, config drift — I rebuilt it properly. Not just “make it work”. But “make it reproducible”.

The thing I was replacing was the Pi-hole + PiVPN setup I’d put together a year earlier by hand. It worked fine until the day it didn’t, and then nothing about it was recoverable.

The goal: if the SD card dies, I want to be back online in 20 minutes with the same config. That means Git, k3s manifests, and declarative configuration from day one.

The Homelab Architecture

%%{ init: { 'look': 'handDrawn' } }%%
graph LR
    A[Router DHCP] -->|Assigns IP| B[RPi4<br/>k3s node]
    B --> E[WireGuard<br/>VPN server<br/>Port 51820]
    B --> C[Pi-hole<br/>DNS sinkhole<br/>Port 53]
    F -->|DNS query| D
    B --> D[Unbound<br/>Recursive DNS<br/>Port 5053]
    F[Remote client] -->|VPN WireGuard| E
    D -->|Resolve| G[Public DNS]
    C -->|Blocklist check| H[Ad domains<br/>Firebog]
    C -->|Clean query| D

All services run as k3s pods. Config lives in a git repo.

k3s Installation

curl -sfL https://get.k3s.io | sh -
kubectl get nodes

A single-node k3s cluster is lightweight enough for a Pi 4 (2GB RAM minimum, 4GB recommended).

Pi-hole as a Kubernetes Manifest

# pihole.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pihole
  namespace: homelab
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pihole
  template:
    metadata:
      labels:
        app: pihole
    spec:
      containers:
        - name: pihole
          image: pihole/pihole:latest
          ports:
            - containerPort: 53
              name: dns
            - containerPort: 80
              name: web
          env:
            - name: WEBPASSWORD
              valueFrom:
                secretKeyRef:
                  name: pihole-secrets
                  key: web-password
          volumeMounts:
            - name: etc-pihole
              mountPath: /etc/pihole
            - name: etc-dnsmasq
              mountPath: /etc/dnsmasq.d
          resources:
            requests:
              memory: 128Mi
              cpu: 100m
            limits:
              memory: 512Mi
      volumes:
        - name: etc-pihole
          persistentVolumeClaim:
            claimName: pihole-etc-pihole
        - name: etc-dnsmasq
          persistentVolumeClaim:
            claimName: pihole-etc-dnsmasq
---
apiVersion: v1
kind: Service
metadata:
  name: pihole
  namespace: homelab
spec:
  selector:
    app: pihole
  ports:
    - port: 53
      targetPort: 53
      name: dns
  clusterIP: 10.43.0.100

Applying Blocklists via ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: pihole-blocklists
  namespace: homelab
data:
  blocklists: |
    https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
    https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt
    https://v.firebog.net/hosts/AdguardDNS.txt
    https://v.firebog.net/hosts/Easyprivacy.txt

Blocklists are external URLs — the Pi-hole gravity script pulls them on startup. By storing them in a ConfigMap, they’re versioned alongside the deployment manifest.

Auto-Update with a CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: pihole-update
  namespace: homelab
spec:
  schedule: "0 3 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: pihole-update
              image: pihole/pihole:latest
              command: ["pihole", "-g"]
          restartPolicy: OnFailure

Gravity updates run at 3 AM via CronJob — no crontab on the host, no SSH needed.

Unbound for Recursive DNS

apiVersion: v1
kind: ConfigMap
metadata:
  name: unbound-config
  namespace: homelab
data:
  unbound.conf: |
    server:
        port: 5053
        do-not-query-localhost: no
        forward-zone:
            name: "."
            forward-addr: 1.1.1.1@5053
            forward-addr: 8.8.8.8@5053

Pi-hole forwards clean queries to Unbound, which queries public DNS recursively. No upstream resolver dependency.

Wiring It Together

%%{ init: { 'look': 'handDrawn' } }%%
graph LR
    A[Device DNS query] -->|Router forwards<br/>to Pi-hole| B[Pi-hole:53]
    B -->|Check blocklists| C{Match?}
    C -->|Yes| D[Block<br/>NXDOMAIN]
    C -->|No| E[Forward to Unbound:5053]
    E --> F[Recursive resolve<br/>1.1.1.1]
    F --> G[Return result]

Recovery Playbook

When the SD card dies:

# 1. Flash new Raspberry Pi OS
# 2. Install k3s
curl -sfL https://get.k3s.io | sh -

# 3. Clone the git repo
git clone https://github.com/you/homelab.git
cd homelab

# 4. Apply everything
kubectl apply -f namespace.yaml
kubectl apply -f pihole.yaml
kubectl apply -f unbound.yaml
kubectl apply -f wireguard.yaml
kubectl apply -f pihole-blocklists.yaml

# 5. Update router DHCP to point to new Pi-hole IP

Total recovery time: ~20 minutes. All config is in git. No undocumented changes.

That’s the homelab equivalent of “infrastructure as code” — and it teaches the same habits that make production systems maintainable.