Private DNS with Pi-hole and Unbound

Private DNS with Pi-hole and Unbound: DNS Security for Self-Hosted Networks

I used to rely on public DNS resolvers—Cloudflare, Google, whatever—until I realized every DNS query I made was leaking metadata about my network. When I set up Pi-hole with Unbound last year, I gained three critical benefits: absolute ad-blocking at the DNS level, complete DNS query privacy, and the ability to create internal domain records for my homelab services. This isn't a luxury—it's a privacy baseline I can no longer live without.

In this guide, I'll walk you through deploying Pi-hole and Unbound together on a single machine or Docker Compose stack. By the end, you'll have a recursive DNS server that answers to no upstream provider, blocks malware and ads before they touch your devices, and gives you granular control over name resolution across your entire network.

Why Pi-hole and Unbound Together?

Pi-hole is excellent at blocking domains via blocklists and providing a web UI. But by default, it still forwards queries to upstream DNS providers (Cloudflare, Google, etc.). That means Pi-hole sees which domains you visit, but so does your upstream provider.

Unbound is a recursive DNS resolver. It queries the DNS root servers directly, validates responses using DNSSEC, and caches results. When you pair them, Pi-hole becomes your client-facing interface, and Unbound becomes the authoritative backend. No queries leave your network unless you explicitly want them to.

I prefer this architecture because:

Infrastructure: Where to Run This?

For a small homelab or home network, a Raspberry Pi 4 with 4GB RAM is sufficient. If you're running a larger deployment with hundreds of devices, consider a dedicated VPS or small dedicated server from RackNerd—they offer affordable KVM VPS options starting at a few dollars monthly, with plenty of bandwidth for recursive DNS traffic.

I prefer Docker Compose for this setup because it isolates both services, handles networking cleanly, and makes updates painless. You can also install both bare-metal if you prefer—I'll cover the Docker approach here since it's more portable.

Docker Compose Setup: Pi-hole + Unbound

Here's my production-ready Docker Compose configuration. This runs both services with proper networking, persistent data, and security best practices.

version: '3.8'

services:
  unbound:
    image: mvance/unbound:latest
    container_name: unbound
    restart: unless-stopped
    ports:
      - "5353:53/udp"
      - "5353:53/tcp"
    volumes:
      - ./unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
    networks:
      dns_net:
        ipv4_address: 10.0.9.3
    healthcheck:
      test: ["CMD", "drill", "@127.0.0.1", "google.com"]
      interval: 30s
      timeout: 5s
      retries: 3

  pihole:
    image: pihole/pihole:latest
    container_name: pihole
    restart: unless-stopped
    ports:
      - "53:53/udp"
      - "53:53/tcp"
      - "80:80/tcp"
      - "443:443/tcp"
    environment:
      TZ: UTC
      WEBPASSWORD: ${WEBPASSWORD}
      DNS1: 10.0.9.3#5353
      DNS2: 10.0.9.3#5353
      DNSMASQ_LISTENING: all
    volumes:
      - ./pihole/etc-pihole:/etc/pihole
      - ./pihole/etc-dnsmasq.d:/etc/dnsmasq.d
    networks:
      dns_net:
        ipv4_address: 10.0.9.2
    depends_on:
      unbound:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "dig", "@127.0.0.1", "google.com"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  dns_net:
    driver: bridge
    ipam:
      config:
        - subnet: 10.0.9.0/24

Before running this, create an .env file in the same directory:

WEBPASSWORD=your_secure_password_here

Then start the stack:

docker-compose up -d
Tip: Use a strong, unique password for the Pi-hole web UI. This becomes your admin panel for managing blocklists and DNS records. I generate mine with openssl rand -base64 16.

Configuring Unbound for Recursive Resolution

Create the unbound.conf file that the Docker Compose references. This configuration enables recursive resolution, DNSSEC validation, and sensible caching:

server:
    port: 53
    do-ip4: yes
    do-ip6: yes
    do-udp: yes
    do-tcp: yes
    
    # Performance tuning
    num-threads: 4
    msg-cache-size: 50m
    rrset-cache-size: 100m
    outgoing-range: 4096
    
    # Security and privacy
    hide-identity: yes
    hide-version: yes
    qname-minimisation: yes
    use-caps-for-id: yes
    prefetch: yes
    prefetch-key: yes
    
    # DNSSEC validation
    module-config: "validator iterator"
    auto-trust-anchor-file: "/opt/unbound/etc/unbound/root.key"
    
    # Upstream servers for bootstrap (used only initially)
    root-hints: "/opt/unbound/etc/unbound/root.hints"
    
    # Local zone for internal resolution
    local-zone: "local." static
    local-data: "jellyfin.local. IN A 192.168.1.100"
    local-data: "nextcloud.local. IN A 192.168.1.101"
    local-data: "home.local. IN A 192.168.1.1"
    
    # Access control - allow only local networks
    access-control: 0.0.0.0/0 refuse
    access-control: 127.0.0.1/32 allow
    access-control: 192.168.0.0/16 allow
    access-control: 10.0.0.0/8 allow
    access-control: 172.16.0.0/12 allow
    access-control: 10.0.9.0/24 allow

forward-zone:
    name: "."
    forward-addr: 8.8.8.8@53
    forward-addr: 8.8.4.4@53

Notice the local-zone and local-data directives. This is split-horizon DNS in action—I'm telling Unbound to resolve jellyfin.local to my internal IP, while external queries go recursive through the root servers. Customize these entries for your homelab services.

Configuring Pi-hole for Blocklists and Management

Once the containers are running, navigate to http://your-machine-ip/admin and log in with your password. Go to Settings → DNS and verify that both DNS servers are pointing to Unbound:

Next, add blocklists. I typically use:

Go to Adlists, paste each URL, and enable them. Pi-hole will download and parse these into its blocking engine. The first sync can take a minute or two—watch the Tools → Gravity page to see progress.

I avoid overly aggressive blocklists that block legitimate domains. A few quality sources are better than dozens of low-quality ones. Test your setup by visiting a known ad network domain (like doubleclick.net) and confirming it resolves to 0.0.0.0.

Watch out: If you add too many blocklists at once, Gravity can consume significant memory and CPU during updates. Start with 3–5 high-quality lists, monitor performance, and expand gradually. On a Raspberry Pi 3 or older, stick to 2–3 lists maximum.

Pointing Your Network to This DNS Server

For this to work across your homelab, every device needs to use your Pi-hole/Unbound server as its primary DNS resolver.

Option 1: DHCP (Recommended)

Log into your router and change the DHCP DNS settings to point to your Pi-hole machine's IP (e.g., 192.168.1.50). This pushes DNS config to all devices automatically.

Option 2: Static per-device

On each device (or in Docker containers), set DNS manually to your Pi-hole IP. In Docker Compose, add:

services:
  my_app:
    dns:
      - 192.168.1.50
    dns_search:
      - local

Option 3: Internal network only

If you're running this on a remote VPS, configure only your homelab containers and local machines to use it. Public DNS clients will still use system defaults.

Monitoring and Maintenance

Pi-hole's dashboard shows real-time queries, blocked domains, and network activity. I check it weekly to:

To view live logs from both services:

docker-compose logs -f unbound
docker-compose logs -f pihole

If Unbound stops responding, restart it without affecting Pi-hole:

docker-compose restart unbound

Update both images monthly to patch security vulnerabilities:

docker-compose pull
docker-compose up -d

Performance Tuning for Larger Networks

If you're serving 50+ devices or running multiple homelabs, increase Unbound's thread count in unbound.conf:

server:
    num-threads: 8
    msg-cache-size: 200m
    rrset-cache-size: 400m

And increase Pi-hole's Docker memory allocation in docker-compose.yml:

pihole:
  mem_limit: 1G
  memswap_limit: 1G

Monitor actual usage with:

docker stats pihole unbound

Wrapping Up

You now have a private, recursive DNS server that blocks ads at the source, validates DNSSEC responses locally, and keeps all query metadata on your network. This is the foundation of a truly private homelab.

Next steps: add local DNS records for each of your self-hosted services, configure conditional forwarding for split-horizon DNS if needed, and consider setting up UFW firewall rules to restrict DNS access to your internal networks only.

If you're planning to scale this beyond a single machine or need redundancy, look into running a second Pi-hole + Unbound pair on a separate host. Or, for those wanting a managed solution, RackNerd's affordable KVM VPS can host a production-grade DNS server with excellent uptime guarantees.

Discussion