cherif@kali:~/articles/ultimate-dns-shield
~/portfolio

Ultimate DNS Shield — Self-hosted recursive DNS with Pi-hole + Unbound + Docker Ultimate DNS Shield — DNS récursif auto-hébergé avec Pi-hole + Unbound + Docker


Why self-host your DNS? Pourquoi auto-héberger son DNS ?

Every domain you resolve goes through a DNS resolver. By default, that's Google (8.8.8.8), Cloudflare (1.1.1.1), or your ISP — all of which log your queries to varying degrees. Your browsing history is essentially visible to them.

The goal of Ultimate DNS Shield is to break that dependency entirely: no third-party resolver ever sees your queries. Unbound resolves domains by querying the root nameservers directly, and Pi-hole drops ad and tracker domains before they even reach the network stack.

This is not about hiding from law enforcement — it's about reducing unnecessary data exposure to commercial entities and your ISP.

Chaque domaine que tu résous passe par un résolveur DNS. Par défaut, c'est Google (8.8.8.8), Cloudflare (1.1.1.1), ou ton FAI — qui journalisent tous tes requêtes à des degrés variables. Ton historique de navigation leur est en pratique visible.

L'objectif d'Ultimate DNS Shield est de briser cette dépendance : aucun résolveur tiers ne voit tes requêtes. Unbound résout les domaines en interrogeant directement les serveurs racine, et Pi-hole bloque les domaines publicitaires et trackers avant même qu'ils n'atteignent la pile réseau.

L'objectif n'est pas de se cacher des autorités — c'est de réduire l'exposition inutile de données à des entités commerciales et à ton FAI.

Architecture

The stack runs three layers, each containerised on an isolated Docker bridge network (dns-net). Unbound is never exposed to the LAN — only Pi-hole can reach it.

La stack tourne sur trois couches, chacune conteneurisée sur un réseau bridge Docker isolé (dns-net). Unbound n'est jamais exposé au LAN — seul Pi-hole peut l'atteindre.

LAN devices ──► Pi-hole :53 (172.20.0.3)
│
└──► Unbound :5335 (172.20.0.2) — internal only
│
└──► Root nameservers (a-m.root-servers.net)

IP assignments Assignation des IPs

docker-compose.yml (extract)
# Internal bridge — 172.20.0.0/24
networks:
  dns-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24

services:
  unbound:
    image: mvance/unbound:latest
    networks:
      dns-net:
        ipv4_address: 172.20.0.2   # never exposed to LAN

  pihole:
    image: pihole/pihole:latest
    networks:
      dns-net:
        ipv4_address: 172.20.0.3
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "80:80/tcp"   # Web UI

Pi-hole is configured with PIHOLE_DNS_=172.20.0.2#5335 so it forwards unblocked queries to Unbound, not to any public resolver.

Pi-hole est configuré avec PIHOLE_DNS_=172.20.0.2#5335 pour transmettre les requêtes non bloquées à Unbound, et non à un résolveur public.

Prerequisites Prérequis

  • Raspberry Pi 4 (2 GB RAM minimum)
  • Raspberry Pi OS Lite 64-bit (fresh install)
  • Static LAN IP assigned to the Pi (via DHCP reservation or /etc/dhcpcd.conf)
  • Docker + Docker Compose installed
  • Port 53 free on the host (disable systemd-resolved if needed)
On Ubuntu/Pi OS, systemd-resolved occupies port 53 by default. Disable it before deploying: sudo systemctl disable --now systemd-resolved
  • Raspberry Pi 4 (2 Go RAM minimum)
  • Raspberry Pi OS Lite 64-bit (installation fraîche)
  • IP LAN statique assignée au Pi (réservation DHCP ou /etc/dhcpcd.conf)
  • Docker + Docker Compose installés
  • Port 53 libre sur l'hôte (désactiver systemd-resolved si nécessaire)
Sur Ubuntu/Pi OS, systemd-resolved occupe le port 53 par défaut. Le désactiver avant le déploiement : sudo systemctl disable --now systemd-resolved

Installation

1 — Clone and configure 1 — Cloner et configurer

bash
$ git clone https://github.com/cherifon/Ultimate-DNS-Shield.git
$ cd Ultimate-DNS-Shield

Edit config/docker-compose.yml — three values to adjust:

Éditer config/docker-compose.yml — trois valeurs à ajuster :

docker-compose.yml
environment:
  TZ: Europe/Paris          # your timezone
  WEBPASSWORD: your_password  # Pi-hole web UI password
volumes:
  - /home/pi/pihole:/etc/pihole    # persistent config path
  - /home/pi/dnsmasq:/etc/dnsmasq.d

2 — Run the installer 2 — Lancer l'installeur

A dry-run mode is available to preview changes without applying them:

Un mode dry-run est disponible pour prévisualiser les changements sans les appliquer :

bash
# Preview (no changes applied)
$ sudo bash scripts/install.sh --dry-run

# Full install
$ sudo bash scripts/install.sh

3 — Router configuration 3 — Configuration du routeur

Once the containers are up, point your router's DHCP DNS to your Pi's LAN IP. Every device on the network will automatically use Pi-hole without any per-device configuration.

Alternatively, set the DNS per-device or per-VLAN if you want selective coverage.

Une fois les conteneurs lancés, pointer le DNS DHCP du routeur vers l'IP LAN du Pi. Chaque appareil du réseau utilisera automatiquement Pi-hole sans configuration individuelle.

Alternative : configurer le DNS par appareil ou par VLAN pour une couverture sélective.

Security hardening Durcissement sécurité

The scripts/security.sh script configures UFW and Fail2Ban automatically. Here's what it applies:

Le script scripts/security.sh configure UFW et Fail2Ban automatiquement. Voici ce qu'il applique :

bash
$ sudo bash scripts/security.sh
  • UFW rules — allow port 53 (DNS) and 80 (Pi-hole UI) from LAN only; deny everything else
  • Fail2Ban — rate-limits DNS query floods and brute-force attempts on the web UI
  • DNSSEC validation — enforced in Unbound (val-permissive-mode: no); queries with invalid signatures are rejected
  • Unbound isolation — bound to 172.20.0.2 only, never reachable from outside Docker's internal bridge
  • Règles UFW — autorise le port 53 (DNS) et 80 (UI Pi-hole) depuis le LAN uniquement ; tout le reste est bloqué
  • Fail2Ban — limite les floods de requêtes DNS et les tentatives de brute-force sur l'interface web
  • Validation DNSSEC — appliquée dans Unbound (val-permissive-mode: no) ; les requêtes avec des signatures invalides sont rejetées
  • Isolation Unbound — lié à 172.20.0.2 uniquement, jamais accessible depuis l'extérieur du bridge interne Docker

Verification Vérification

Three checks to confirm the stack is working correctly:

Trois vérifications pour confirmer que la stack fonctionne correctement :

bash — normal resolution
$ dig github.com @<PI_IP>
;; Query time: 8 msec
;; SERVER: 192.168.1.X#53  ✓ answered by your Pi
bash — ad blocking
$ dig doubleclick.net @<PI_IP>
;; ANSWER SECTION:
doubleclick.net.  0 IN A 0.0.0.0  ✓ blocked by Pi-hole
bash — DNSSEC validation
$ dig sigfail.verteiltesysteme.net @<PI_IP>
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL
✓ invalid DNSSEC signature correctly rejected

Results Résultats

  • No DNS query ever leaves the network unresolved by a third party
  • Ad and tracker domains blocked network-wide — no per-device config needed
  • DNSSEC enforced — invalid signatures are dropped
  • First-query latency ~8ms on LAN; cached queries <1ms
  • Entire stack managed via two scripts; updates in one command
The project is open-source under CC BY-NC 4.0. Clone it, adapt it, attribute it.
  • Aucune requête DNS ne quitte le réseau sans être résolue localement
  • Domaines publicitaires et trackers bloqués à l'échelle du réseau — aucune config par appareil
  • DNSSEC appliqué — les signatures invalides sont rejetées
  • Latence première requête ~8ms sur LAN ; requêtes en cache <1ms
  • Stack entière gérée via deux scripts ; mises à jour en une commande
Le projet est open-source sous CC BY-NC 4.0. À cloner, adapter, attribuer.
dns pi-hole unbound docker raspberry-pi privacy homelab dnssec self-hosted