Sunday, September 20, 2026

Running Pi‑hole on k3s and how GenAI tried to make me run as root

I was wrestling with Pi‑hole on single node Kubernetes and hit the classic privileged‑port problem: DNS wants port 53, containers don’t. The first GenAI suggestions were blunt, run the container as root. After some challenging from my side, it pivoted to MetalLB which is a nice option in a multi node setup. After digging I discovered a much cleaner path: k3s’s built‑in load balancer (ServiceLB, formerly known as Klipper LoadBalancer). Configure Pi-hole to use port 5353 and map it to 53 at the ServiceLB, no root, no extra components, and it worked like a charm.

The bad advice: root and the MetalLB detour

I was porting a setup to k3s and one of the main challenges I had was how to expose the DNS port from withing a pod. When I asked for help, the GenAI responses were persuasive but risky:

  • Run the container as root so it can bind port 53. That “fix” solves the bind problem immediately, but it opens a huge security surface: processes running as root inside containers are a maintenance and attack vector you don’t want for network services. As a rule of thumb always challenge pods that have hostNetwork: true or privileged: true under securityContext.

  • Install MetalLB to create a virtual ip and map port 53 there. MetalLB is a solid project for bare‑metal clusters, but it felt like overkill for my small single node k3s cluster.

Both suggestions were workable in theory, but neither was the simplest, safest option for my setup.

The simple, correct fix with k3s

k3s bundles a tiny, pragmatic load balancer that handles Service objects of type LoadBalancer. Instead of changing container privileges or adding MetalLB, I created a LoadBalancer service that exposes port 53 and forwards traffic to the pod’s listening port 5353.

Here’s the idea in YAML (trimmed to the essentials):

apiVersion: v1
kind: Service
metadata:
  name: pihole
  namespace: dns
spec:
  type: LoadBalancer
  selector:
    app.kubernetes.io/name: pihole-app
  ports:
    - name: dns-udp
      port: 53
      protocol: UDP
      targetPort: dns-udp
    - name: dns-tcp
      port: 53
      protocol: TCP
      targetPort: dns-tcp

To force pihole to use that port, I just had to set the environment variable FTLCONF_dns_port to "5353":

          env:
            - name: FTLCONF_dns_port
              value: "5353"

expose both UDP and TCP containerPorts:

          ports:
            - name: dns-udp
              containerPort: 5353
              protocol: UDP
            - name: dns-tcp
              containerPort: 5353
              protocol: TCP

and the container runs unprivileged, the cluster handles the privileged port mapping, and everything stays secure and simple.

Final thoughts

GenAI steered me into two common traps, “just run as root” and “install the shiny external project”. Both options would have been heavier or riskier than necessary. A quick look at what k3s already provides saved time and kept the deployment secure.

No comments: