# ServiceDefinition: pihole # Source of truth: spec docs/13-service-catalog.md, row 6. # internal | egress internet | config on Longhorn (DR-critical config) | auth forward-auth. # Also a platform DNS dependency (docs/09). Validates against schemas/servicedefinition.schema.json. name: pihole kind: container source: image: pihole/pihole:2026.10.0 exposure: internal # LAN-only admin UI (docs/13 row 6; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) storage: - name: config size: 2Gi class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03) # THE PVC WAS MOUNTED SOMEWHERE PIHOLE NEVER WRITES, AND SO HELD NOTHING AT ALL. Without an # explicit mountPath the renderer defaults to /data/, i.e. /data/config — but PiHole keeps # every piece of its state in /etc/pihole. MEASURED on live prod 2026-10-04: `/etc/pihole` (7.1 M # — gravity.db, pihole.toml, the query DB) was on the **overlay** filesystem, i.e. the # container's EPHEMERAL layer, while `/dev/longhorn/pvc-849edb86...` sat mounted at # /data/config holding `lost+found` and 24 K of nothing. Three consequences, all live until now: # * every restart silently discarded PiHole's whole state, re-downloading 72 k gravity domains # and regenerating the admin password hash; # * `backup.policy: daily-offcluster` below has been faithfully backing up an EMPTY volume, # which is worse than no backup because it reports success; # * it is also why phase 1's password/adlist declaration mattered more than it first appeared — # that state was not merely undeclared, it was ephemeral. # This is the same class of bug catalog/plex.yaml's `config` entry documents ("Without this the # PVC sat unused and Plex wrote to the container's EPHEMERAL layer"). # # /etc/pihole IS THE IMAGE'S INTENDED MOUNT POINT, and an EMPTY volume there self-initialises. # Verified in the running image, not assumed: # * `/usr/bin/start.sh` (PID 1) calls `migrate_gravity`, whose `migrate_to_database()` creates # the gravity database when `[ ! -e "$gravityDBfile" ]` — the same branch the postStart hook # below relies on, and the branch that produced the current ephemeral copy. # * FTL writes `pihole.toml` itself from the `FTLCONF_*` env above (that is what the # `### CHANGED (env)` markers in it are). # * NO `podSecurityContext.fsGroup` is needed and adding one would be inert: `start.sh` line 5 # is `chown -R pihole:pihole /etc/pihole/ /var/log/pihole/` and PID 1 runs as **uid 0**, so # the image fixes ownership of a fresh root-owned volume before anything else starts. Same # arrangement as plex's image managing its own /config ownership. # * the image's own default for `files.macvendor` was moved OUT of this directory (to # `/macvendor.db`), which is what a maintainer does to a path they expect to be a volume. # # WHY IT IS FIXED IN THIS BRANCH rather than left for its own: `availability.replicas: 2` renders # per-replica volumeClaimTemplates, and with the wrong mountPath those would be two 2 Gi Longhorn # volumes (times three Longhorn replicas each) holding nothing but `lost+found` while all state # stayed ephemeral — paying the cost of persistence and getting none of it. It also REMOVES this # change's main operational risk: with gravity.db persisted, a pod restart is cheap, instead of # every restart re-downloading 72 k domains inside a wave that has a 5 m timeout. mountPath: /etc/pihole dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02) # ------------------------------------------------------------------------------------------------ # LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to. # ------------------------------------------------------------------------------------------------ # coredns-custom answers the ${BASE_DOMAIN} zone for PODS ONLY and says so in its own header ("LAN # clients keep their own answers (PiHole split-horizon)"). Until now PiHole served no DNS at all — # its Service was ClusterIP :80/:4180, no port 53, no LoadBalancer — so the LAN half did not exist # and LAN clients depended on hand-maintained per-client dnsmasq files. One went stale after the dev # decommission and cost the operator sonarr.${BASE_DOMAIN}. Upstream cannot cover for it either: the # SOHO router that owns this LAN strips RFC1918 answers for public domains as rebind protection and # returns NOERROR / ANSWER 0. apps/wave-0/pihole-dns.yaml exposes udp/53 + tcp/53 on # ${PIHOLE_DNS_VIP}; these env vars are what make the ANSWERS declarative. # # WHY env AND NOT A RECONCILER: the repo's level-triggered CronJob reconcilers # (platform/keycloak/client-secret-reconciler.yaml, the CNPG grant reconciler in # apps/wave-0/nextcloud-db.yaml) exist because their state has NO declarative entry point — a # Keycloak client secret lives in Keycloak's own database and a Postgres GRANT lives in the # catalog, and nothing in a manifest can express either. PiHole v6 is not that case. Every FTL # setting has an `FTLCONF_
_` environment variable, and a value supplied that way is # ENV-FORCED: FTL logs it as "forced through environment", applies it on every start ahead of # /etc/pihole/pihole.toml, and marks it read-only in the admin UI so a human cannot drift it. That # is strictly better than a reconciler — it is enforced at t=0 on a cold start rather than at the # next cron tick, it cannot be defeated by the UI, and it carries no extra RBAC, image or Job. A # CronJob here would be ceremony around a knob the app already exposes. (Verified on the live prod # image, PiHole Core v6.4.3 / FTL v6.7, 2026-09-14.) # # THE PVC IS NOT THE MECHANISM AND MUST NOT BE. The `config` volume below mounts at /data/config; # PiHole's actual state directory is /etc/pihole, which is EPHEMERAL in this Deployment. Anything # configured through the UI is lost on the next reschedule — which is exactly what happened to the # upstream servers: the live pod's pihole.toml carried a hand-set `upstreams` marked "### CHANGED" # that no manifest declared, so a restart would have left PiHole with upstreams = [] and no # recursion at all. Declaring upstreams here closes that too: PiHole must stay a usable GENERAL # resolver, not only authoritative for this zone, or pointing LAN clients at it breaks the internet. env: # Default is LOCAL, which answers only clients on the same subnet as the listening interface. The # pod's interface is on the CLUSTER pod CIDR, so every LAN client would be refused. ALL is # required for a LoadBalancer-fronted resolver; what keeps PiHole from being an OPEN resolver is # the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_CIDR}]` on the DNS Services, # enforced by Cilium in the datapath before load-balancing, plus a CiliumNetworkPolicy that admits # nothing but udp/53 and tcp/53. See apps/wave-0/pihole-dns.yaml. - name: FTLCONF_dns_listeningMode value: "ALL" # Recursion for everything that is NOT this zone. Public resolvers on purpose, NOT the SOHO # router at the LAN gateway: that router is the box whose rebind protection strips RFC1918 # answers, and forwarding to a device that may forward back here invites a loop. - name: FTLCONF_dns_upstreams value: "8.8.8.8;8.8.4.4" # Per-client query rate limit OFF (default: 1000 queries per 60s, then REFUSED). It has to be off # because of how the DNS Services are exposed: apps/wave-0/pihole-dns.yaml uses # externalTrafficPolicy Cluster — it must, or LAN resolution breaks whenever Cilium's L2 # announcement lease and the pihole pod land on different nodes, which is most of the time — and # Cilium runs in SNAT mode here, so a query forwarded between nodes reaches PiHole carrying the # INGRESS NODE's address instead of the client's. Every LAN device therefore counts against ONE # apparent client's budget, and a normal evening of browsing from a handful of machines would trip # a limit meant to catch one misbehaving host. Leaving it at the default turns the whole LAN's DNS # off for 60 seconds at a time, intermittently, which is the worst possible failure shape. The # limit becomes meaningful again — and should be restored — if Cilium is ever switched to # `loadBalancer.mode: dsr`, which preserves the client address. - name: FTLCONF_dns_rateLimit_count value: "0" # THE SPLIT-HORIZON ANSWERS. A dnsmasq `address=//` entry answers that domain AND # every subdomain of it, and dnsmasq resolves overlapping entries by LONGEST MATCHING DOMAIN — # so the first line is a wildcard for the whole zone and each later line is a specific override. # PRECEDENCE IS THEREFORE STRUCTURAL, not order-dependent: `unifi.${BASE_DOMAIN}` matches both # line 1 and line 2, line 2 is the longer domain, line 2 wins. Rewriting these in any order # changes nothing. # # A NEWLY ONBOARDED SERVICE NEEDS NO CHANGE HERE. Every catalog ingressRoute host is # `