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 → 7 → 8 → 11amber = 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.
Find the box's MAC address:
on the box
ip link show eth0
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).
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.
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.
Apply CAKE on egress:
on the box
sudo tc qdisc add dev eth0 root cake bandwidth 80mbit
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.
Install, fetch root hints and the DNSSEC trust anchor:
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.
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
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
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.
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
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.
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.
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
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
On the phone tunnel, set:
Field
Value
Server endpoint
your home's public IP (or a free DDNS name — see note) : 51820
DNS
10.8.0.1
Allowed IPs
0.0.0.0/0 — not::/0
Persistent keepalive
25
Adding ::/0 black-holes IPv6: the tunnel handshakes fine but pages that prefer IPv6 just hang. Leave IPv6 out.
Port-forward 51820/UDP → 192.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.
Install Docker first if you haven't (Step 3, above), then:
Open http://192.168.1.50:3001 and create the admin account — do this from a trusted device, first account wins.
Add a few monitors (Add Monitor → type DNS or HTTP(s)):
Name
Type
Hostname
Resolver
Port
Unbound
DNS
cloudflare.com
127.0.0.1
5335
Pi-hole
DNS
cloudflare.com
127.0.0.1
53
Router
HTTP(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.
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).
Set:
Field
Value
Primary DNS
192.168.1.50
Secondary DNS
1.1.1.1 — pure failover; if the box ever goes down, the house stays online (just without ad-blocking) instead of losing internet entirely
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.