First boot. The Pi powers on. Unbound starts. Pi-hole container launches. Everything looks fine.

Then nothing happens. No errors. No logs. Just… silence. Pi-hole’s web UI is up but shows zero queries and an empty blocklist.

I spent two hours debugging this before realizing what was going on: Pi-hole needs to download its blocklist from the internet. To download anything, it needs DNS. But Pi-hole is the DNS server. And it’s configured to use itself for lookups.

Chicken and Egg

To serve DNS, it needs DNS. What comes first, the chicken or the egg?

The Problem in Detail

When Pi-hole starts fresh, it has no blocklist. The default list URLs (like https://raw.githubusercontent.com/...) need to be fetched from the internet.

To fetch anything over HTTPS, Pi-hole needs to:

  1. Resolve the domain name to an IP address (DNS lookup)
  2. Connect to that IP
  3. Download the file

Step 1 is the problem. Pi-hole’s container is configured to use 127.0.0.1 as its DNS - which is itself. But it can’t resolve anything yet because it just started and has no data.

The container just sits there. No error, because the DNS query doesn’t fail - it just has no resolver to answer it. It times out eventually, but by then the startup sequence has moved on.

Why Most Guides Don’t Mention This

Most Pi-hole tutorials assume you’re installing on a system that already has working DNS. Raspbian comes pre-configured to use your router or ISP for DNS. When Pi-hole starts, it downloads blocklists using that existing DNS, then switches to serving DNS itself.

With finite, I’m building an SD card image from scratch. There’s no “existing DNS” - the Pi boots directly into being a DNS server. The assumption that DNS already works doesn’t hold.

The Solution: Temporary External DNS

The fix is ugly but effective: temporarily point Pi-hole at an external DNS provider (like Cloudflare), download the blocklist, then switch back to local resolution.

First, a script that waits for the container, swaps DNS to Cloudflare, downloads the blocklist, then switches back:

postinit = pkgs.writeShellScript "pi-hole-postinit.sh" ''
  set -euo pipefail
 
  # wait until container is reachable
  while ! ${pkgs.podman}/bin/podman exec -i pi-hole true 2>/dev/null; do
    sleep 1
  done
 
  # Point at Cloudflare just long enough to fetch the blocklist
  ${pkgs.podman}/bin/podman exec -i pi-hole sh -lc 'printf "nameserver 1.1.1.1\nnameserver 1.0.0.1\n" >/etc/resolv.conf'
  ${pkgs.podman}/bin/podman exec -i pi-hole pihole -g
  ${pkgs.podman}/bin/podman exec -i pi-hole sh -lc 'printf "nameserver 127.0.0.1\nnameserver ::1\noptions edns0 trust-ad\n" >/etc/resolv.conf'
'';

Then a systemd service that runs this script exactly once on first boot:

systemd.services.pi-hole-postinit = {
  description = "One-time post-init inside pi-hole container";
  after = [ "podman-pi-hole.service" ];
  requires = [ "podman-pi-hole.service" ];
  wantedBy = [ "multi-user.target" ];
 
  # run only once
  unitConfig.ConditionPathExists = "!/var/lib/pi-hole-postinit.stamp";
 
  serviceConfig = {
    Type = "oneshot";
    ExecStart = "${postinit}";
    ExecStartPost = ''
      ${pkgs.coreutils}/bin/mkdir -p /var/lib
      ${pkgs.coreutils}/bin/touch /var/lib/pi-hole-postinit.stamp
    '';
  };
};

The ConditionPathExists trick ensures it only runs once: first boot the stamp file doesn’t exist so the script runs and creates it. Every subsequent boot the stamp file exists, so systemd skips the service entirely.

Full module: finite/modules/containers/pi-hole.nix

The Unbound TLS Problem

While debugging this, I hit another bootstrap issue: Unbound was failing on first boot with TLS certificate errors.

Turns out, DNS-over-TLS validates certificates using the system clock. If the Pi boots with the wrong time (common for Raspberry Pis, which have no battery-backed RTC), certificate validation fails because the cert appears to be “not yet valid” or “expired.”

The fix: sync time via NTP before Unbound starts. But NTP also needs DNS to resolve time.cloudflare.com or whatever server you use.

More chicken-and-egg.

The solution is to use NTP servers by IP address instead of hostname. In settings.nix:

# Cloudflare NTP usage: https://developers.cloudflare.com/time-services/ntp/usage/#linux
# Unbound fails on first boot if system clock is wrong (TLS cert validation).
TIMESYNCD_SERVERS = [ "162.159.200.1" "162.159.200.123" ];

These are Cloudflare’s NTP server IPs. No DNS lookup needed. The Pi syncs time first, then Unbound can start with valid TLS.

Service Ordering

Getting all this right requires careful service ordering. In finite:

  1. Network comes up (static IP configured)
  2. Time syncs (NTP to hardcoded IPs)
  3. Unbound starts (TLS now works because clock is correct)
  4. Pi-hole container starts (depends on Unbound)
  5. Post-init runs (downloads blocklist using Cloudflare, then switches to local)

NixOS/systemd handles the dependencies in pi-hole.nix:

systemd.services.docker-pi-hole.after = [ "unbound.service" ];
systemd.services.docker-pi-hole.requires = [ "unbound.service" ];

Lessons Learned

Test from scratch. The bootstrap problem only appears on first boot. If you’re iterating on a running system, you’ll never see it. I had to flash fresh SD cards repeatedly to debug this.

Silence is the worst error. A failed DNS lookup in a container doesn’t crash anything - it just silently times out. No logs, no indication of what went wrong. I learned to add more verbose logging temporarily when debugging network issues.

Hardcode IPs for bootstrapping. Anything that needs to happen before DNS works must use IP addresses directly. NTP servers, initial DNS for blocklist download, etc.

Run once, document why. The stamp file pattern is simple but easy to forget. I added comments explaining the chicken-and-egg problem so future-me doesn’t remove it thinking it’s cruft.

All the code is in the finite repository.

Next in this series: Deployment Guide - step-by-step instructions to get finite running on your network.