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.