Home DNS — Field Guide
a private, ad-free network in one build session
0 of 6 steps done
Why build this

Every device in your house — phones, laptops, TVs — asks a resolver "where is this site?" thousands of times a day, and by default that resolver is your ISP or a public one that can log every domain you touch. This guide turns one small always-on computer into your own resolver: it blocks ads and trackers network-wide, resolves everything sensitive itself (no third party sees it), and optionally gives you the same protection on your phone over cellular.

Fill in your own values once below — every command on this page updates to match, ready to copy.

$30–70
Hardware, used
~10 W
Power draw
2–4 hrs
Core build time
Terminal
Comfort level needed
Step 0 of the app, not the build

Your setup

Fill this in once — it's saved on this device and every command block below updates live. Defaults are the most common home-router values; check yours if unsure.

Internet
your ISP
Your router
192.168.1.1
Your box
192.168.1.50
Pi-hole → Unbound
Phones, laptops, TVs
every query filtered + private
Actual build order: 0 → 1 → 2 → 3 → 4+5 → 6 → 78 → 11 amber = optional, shown/hidden by your toggles above

Build blocks

If the box's address ever changes, every device pointed at it loses DNS at once. A DHCP reservation pins it permanently before anything else is built on top.

  1. Find the box's MAC address:

    on the box
    ip link show eth0
  2. Log into your router at 192.168.1.1 and find the reservation page — LAN Setup (Netgear), Address Reservation (TP-Link), or LAN → DHCP Server (ASUS).

  3. Reserve 192.168.1.50 for that MAC address, save, and give the router a minute to apply it — some models reboot.

Verify
on the box
ip a

Confirms 192.168.1.50 — reboot the box once and check again, since the whole point is that it doesn't move.

Only matters once you route real traffic through the box (Step 7's VPN) — without shaping, saturating your upload adds hundreds of milliseconds of lag to everything else in the tunnel, video calls included. CAKE fixes it by keeping the queue short.

  1. Run a real speed test and use roughly 85% of your measured upload as the shaping ceiling — leaving headroom is what actually kills the bufferbloat, so don't just use your ISP's advertised number.

  2. Apply CAKE on egress:

    on the box
    sudo tc qdisc add dev eth0 root cake bandwidth 80mbit
  3. That command doesn't survive a reboot on its own — add a tiny systemd oneshot unit that re-runs it at boot, or install the sqm-scripts package for a config file that persists automatically.

Verify
on the box
tc qdisc show dev eth0

Run a bufferbloat test (e.g. waveform.com/tools/bufferbloat) with the VPN under load, before and after — loaded latency should drop from hundreds of ms to near-idle.

This is the actual decision point. Instead of asking a company's resolver "where is chase.com?", Unbound walks the DNS hierarchy itself — root → .com → chase.com's own nameservers — so no single third party ever sees your full lookup history, and DNSSEC rejects tampered answers along the way.

  1. Install, fetch root hints and the DNSSEC trust anchor:

    on the box
    sudo apt update && sudo apt install -y unbound ca-certificates
    sudo curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.root
    sudo unbound-anchor -a /var/lib/unbound/root.key
    sudo chown unbound:unbound /var/lib/unbound/root.key
  2. Write the config — listens on 5335 so Pi-hole (which owns port 53) can sit in front of it:

    on the box
    sudo tee /etc/unbound/unbound.conf.d/server.conf <<'EOF'
    server:
        interface: 127.0.0.1
        port: 5335
        access-control: 127.0.0.1/32 allow
        do-ip4: yes
        do-udp: yes
        do-tcp: yes
        hide-identity: yes
        hide-version: yes
        harden-glue: yes
        harden-dnssec-stripped: yes
        use-caps-for-id: yes
        qname-minimisation: yes
        prefetch: yes
        root-hints: "/var/lib/unbound/root.hints"
        auto-trust-anchor-file: "/var/lib/unbound/root.key"
    EOF
    sudo unbound-checkconf
    sudo systemctl enable --now unbound
Verify
on the box
dig @127.0.0.1 -p 5335 example.com +dnssec | grep "ad;"

The ad (authenticated data) flag means DNSSEC validation is live — a tampered answer would be rejected instead of trusted.

Pi-hole and the optional monitoring dashboard both run as containers — from Docker's own repo, not the older docker.io Ubuntu package.

  1. Add Docker's repository and install:

    on the box
    sudo apt-get install -y ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
      https://download.docker.com/linux/ubuntu \
      $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
      | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt-get update
    sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo systemctl enable --now docker
    sudo usermod -aG docker you
  2. Log out and back in (or run exec su -l you in the same shell) — the group change isn't active in your current session. Skipping this shows "permission denied on socket" the moment you run docker compose.

Verify
on the box
docker run hello-world

Ubuntu's own DNS stub already sits on port 53. Pi-hole needs that port for itself, so it has to be freed first — and since freeing it also disconnects the box's own DNS, both moves happen together so the box is never left without name resolution.

  1. Part A — hand the box's own DNS to public resolvers and free the port:

    on the box
    sudo mkdir -p /etc/systemd/resolved.conf.d
    sudo tee /etc/systemd/resolved.conf.d/host-dns.conf <<'EOF'
    [Resolve]
    DNS=9.9.9.9 1.1.1.1
    DNSStubListener=no
    EOF
    sudo systemctl restart systemd-resolved
    sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
    getent hosts security.ubuntu.com   # must return an IP
    sudo ss -ulpn 'sport = :53'        # must show nothing yet
  2. Part B — bring up Pi-hole:

    on the box
    mkdir -p ~/pihole && cd ~/pihole
    cat > docker-compose.yml <<'EOF'
    services:
      pihole:
        image: pihole/pihole:latest
        container_name: pihole
        network_mode: host
        environment:
          TZ: 'America/New_York'
          FTLCONF_webserver_api_password: 'CHANGE_ME'
          FTLCONF_dns_upstreams: '127.0.0.1#5335'
          FTLCONF_dns_listeningMode: 'all'
        volumes:
          - './etc-pihole:/etc/pihole'
        restart: unless-stopped
    EOF
    nano docker-compose.yml   # set a real password + your timezone before starting
    docker compose up -d
  3. Open http://192.168.1.50:8080/admin/, log in with the password you set, and confirm Settings → DNS shows upstream 127.0.0.1#5335 with no public resolvers checked — Pi-hole hands everything to Unbound, which owns the privacy/speed split.

Verify
on the box
docker compose logs pihole | grep -i listening

If it can't bind :53, the stub listener is still up — recheck Part A (sudo ss -ulpn 'sport = :53' should be empty before Pi-hole starts).

Default-deny before you open anything to the internet in Step 7 — DNS and admin ports should only ever answer your own LAN.

  1. Base policy and LAN-only services:

    on the box
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow from 192.168.0.0/16 to any port 22 proto tcp    # SSH
    sudo ufw allow from 192.168.0.0/16 to any port 53               # DNS
    sudo ufw allow from 192.168.0.0/16 to any port 8080 proto tcp  # Pi-hole UI
  2. Only if you're doing Step 7 (VPN): let tunnel peers reach DNS too, allow the box to forward their traffic out, and open the one WAN port:

    on the box · VPN only
    sudo ufw allow from 10.8.0.0/24 to any port 53
    sudo ufw default allow routed
    sudo ufw allow 51820/udp
    "Allow routed" is required for WireGuard traffic to forward — don't try to patch this with raw iptables -A FORWARD rules, they land after UFW's own DROP rule and get silently ignored.
  3. Enable it:

    on the box
    sudo ufw enable
    sudo ufw status verbose
Verify

Status shows 22/53/8080 limited to your LAN, and (if VPN) 51820/udp open to Anywhere — everything else denied.

A tunnel that terminates on your own box, not a VPN company's server — your phone's DNS goes through the same Pi-hole + Unbound stack on cellular as it does at home, and traffic exits from your home IP.

Not the same as a "1.1.1.1" or commercial VPN app — those send your DNS to that company's resolver instead, bypassing everything you just built.
  1. Install and enable forwarding:

    on the box
    sudo apt install -y wireguard
    printf 'net.ipv4.ip_forward=1\n' | sudo tee /etc/sysctl.d/99-wg-forward.conf
    sudo sysctl --system
  2. Generate server keys and write the config:

    on the box
    wg genkey | sudo tee /etc/wireguard/server.key | wg pubkey | sudo tee /etc/wireguard/server.pub
    sudo chmod 600 /etc/wireguard/server.key
    
    sudo tee /etc/wireguard/wg0.conf <<EOF
    [Interface]
    Address = 10.8.0.1/24
    ListenPort = 51820
    PrivateKey = $(sudo cat /etc/wireguard/server.key)
    PostUp   = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
    PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
    
    # Add one [Peer] block per device — see next step
    EOF
    sudo chmod 600 /etc/wireguard/wg0.conf
  3. On your phone, install the official WireGuard app, create a tunnel, and copy its public key. Add a matching peer block on the box, then bring the tunnel up:

    on the box
    sudo tee -a /etc/wireguard/wg0.conf <<EOF
    
    [Peer]
    PublicKey = PASTE_PHONE_PUBLIC_KEY_HERE
    AllowedIPs = 10.8.0.2/32
    EOF
    sudo systemctl enable --now wg-quick@wg0
    sudo wg show
  4. On the phone tunnel, set:

    FieldValue
    Server endpointyour home's public IP (or a free DDNS name — see note) : 51820
    DNS10.8.0.1
    Allowed IPs0.0.0.0/0not ::/0
    Persistent keepalive25
    Adding ::/0 black-holes IPv6: the tunnel handshakes fine but pages that prefer IPv6 just hang. Leave IPv6 out.
  5. Port-forward 51820/UDP192.168.1.50 on the router. If your ISP doesn't give a static public IP, register a free dynamic-DNS name (DuckDNS or No-IP) and use that as the endpoint instead of a raw IP.

Verify — turn off Wi-Fi on the phone first, use cellular

Connect the tunnel, then in order: ping 10.8.0.1 (tunnel itself) → ping 1.1.1.1 (forwarding works) → open a browser (DNS works). Each level that fails points at a different step above.

Uptime Kuma watches DNS, the router, and the connection, and tells you the moment something breaks — instead of finding out when someone yells that the internet is down.

  1. Install Docker first if you haven't (Step 3, above), then:

    on the box
    mkdir -p ~/uptime-kuma && cd ~/uptime-kuma
    cat > docker-compose.yml <<'EOF'
    services:
      uptime-kuma:
        image: louislam/uptime-kuma:1
        container_name: uptime-kuma
        network_mode: host
        volumes:
          - ./data:/app/data
        restart: unless-stopped
    EOF
    docker compose up -d
  2. Open http://192.168.1.50:3001 and create the admin account — do this from a trusted device, first account wins.

  3. Add a few monitors (Add Monitor → type DNS or HTTP(s)):

    NameTypeHostnameResolverPort
    UnboundDNScloudflare.com127.0.0.15335
    Pi-holeDNScloudflare.com127.0.0.153
    RouterHTTP(s)192.168.1.1
    Put the IP alone in the Resolver field — port goes in the separate Port field, or you'll get an invalid double-port and flaky failures.
Verify

All three monitors show green within a minute of saving.

This is the last thing you do — flip it on only once every earlier step verifies clean. It's the switch that makes your whole household use the box.

  1. Open your router's admin page at 192.168.1.1 and find Internet Setup → DNS (wording varies: "DNS Address" on Netgear, "DHCP DNS" on TP-Link/ASUS).

  2. Set:

    FieldValue
    Primary DNS192.168.1.50
    Secondary DNS1.1.1.1 — pure failover; if the box ever goes down, the house stays online (just without ad-blocking) instead of losing internet entirely
  3. Save, then renew DHCP leases on your devices — easiest is toggling Wi-Fi off/on, or a router reboot forces it for everyone at once.

Verify
on any phone or laptop
nslookup example.com

The "Server" field in the reply must show 192.168.1.50 — that's the whole house now asking your box, not the ISP.

Once everything's checked off

Full verification pass

Run these in order on the box. Every line should come back exactly as noted before you trust the build.

on the box
systemctl status unbound                                    # active (running)
dig @127.0.0.1 -p 5335 example.com +dnssec | grep "ad;"      # ad flag present
docker ps                                                    # pihole (and uptime-kuma) Up
dig @192.168.1.50 example.com +short                      # resolves through the full chain
sudo ufw status verbose                                      # LAN-only, plus 51820/udp if VPN
sudo wg show                                                 # interface + peers, if VPN
nslookup example.com                                         # from any client — Server: 192.168.1.50
If something's not working

Troubleshooting

Ads still show up / DNS still points at the old server

Client-side caching, not the box. Forget and rejoin the Wi-Fi network on that device, or reboot it — that forces a fresh DHCP lease with the new DNS setting.

Pi-hole container won't bind port 53

The systemd-resolved stub listener is still holding the port — go back to Step 4+5, Part A, and confirm sudo ss -ulpn 'sport = :53' is empty before starting Pi-hole.

VPN connects but the phone has no internet

Almost always one of two things: IP forwarding isn't enabled (recheck Step 7's sysctl line), or UFW's allow routed is missing (Step 6) — WireGuard traffic reaches the box but never gets forwarded back out.

The box loses its IP after a power cut

The DHCP reservation from Step 0 either wasn't saved or the router reset it. Re-check the reservation page — some routers drop custom reservations on a firmware update.

Streaming (Netflix, YouTube) feels slower than before

Fully recursive resolution is a few extra hops versus a big public resolver. If it bothers you, Unbound supports forwarding specific low-sensitivity domains to a fast encrypted resolver (Cloudflare over DNS-over-TLS) while keeping everything else — banking, email, health — fully private and recursive. That's a deliberate, deeper customization; the public reference implementation this guide is drawn from (a777ance/localdns) documents the exact split and the config file that does it, if you want to go further.