DHCP and Split-Horizon DNS - Local Name Resolution on the LAN
Status: Active
Last Updated: 2026-08-26
Category: Networking - Phase 2: Services
Prerequisites: dns-fundamentals, dns-explained, ip-addressing-subnets
Time: 2 hours
Tags: dnsmasq, dhcp, split-horizon, dns, lan, overrides
Summary
Run dnsmasq on the LAN to serve DHCP leases and split-horizon DNS: internal clients resolve *.fogserv.cloud straight to local RFC1918 addresses while the public records keep pointing at Cloudflare for outside visitors. No hairpin NAT, no CDN round-trips from inside your own network.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Configure dnsmasq as DHCP server with static reservations
- โ Implement split-horizon DNS with local address overrides
- โ Point router/clients at the right resolver safely
- โ Avoid the classic pitfalls: DNS loops, lease conflicts, TLS surprises
Table of Contents
- Context / Why This Matters
- Implementation / Core Content
- Practical Examples
- Troubleshooting & Common Pitfalls
- Next Steps / Ops Actions
Context / Why This Matters
All public services live behind Cloudflare (cloudflare-dns) at edge IPs. When a LAN client resolves grafana.fogserv.cloud it gets the Cloudflare edge โ traffic leaves the house and comes back. Split-horizon DNS fixes this by answering A grafana.fogserv.cloud โ 192.168.20.x internally, keeping traffic on the LAN (faster, works when the WAN is down, no CDN limits). It pairs naturally with reverse proxies that already hold valid certs via DNS-01 (letsencrypt-automation), so internal HTTPS still validates.
Implementation / Core Content
Install dnsmasq
sudo apt install dnsmasq
sudo systemctl disable --now systemd-resolved # free port 53
sudo rm /etc/resolv.conf && echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
/etc/dnsmasq.d/main.conf
# ---- DHCP ----
interface=ens18
bind-interfaces
domain=fogserv.cloud
dhcp-range=192.168.20.100,192.168.20.199,12h
dhcp-option=option:router,192.168.20.1
dhcp-option=option:dns-server,192.168.20.2 # this host
# Static reservations
dhcp-host=b8:27:eb:aa:bb:cc,rpi-cluster01,192.168.20.30,infinite
dhcp-host=52:54:00:11:22:33,truenas,192.168.20.40,infinite
# Don't hand out addresses to unknown MACs on sensitive segments (optional)
# dhcp-ignore=tag:!known
# ---- DNS: split horizon ----
# Local answers first; everything else forwarded upstream
local=/fogserv.cloud/
address=/grafana.fogserv.cloud/192.168.20.10
address=/kuma.fogserv.cloud/192.168.20.10
address=/git.fogserv.cloud/192.168.20.11
cname=media.fogserv.cloud,jellyfin.lan
# Upstream resolvers for everything else
server=1.1.1.1
server=9.9.9.9
# Hygiene
no-resolv
cache-size=2000
log-queries # enable temporarily when debugging; noisy otherwise
no-hosts # we manage names here, not in /etc/hosts
dnsmasq --test # syntax check
sudo systemctl restart dnsmasq
ss -lunp | grep :53 # confirm listening
Pointing clients at it
Two options:
- Router hands out the resolver via its own DHCP WAN/LAN settings โ set DNS to
192.168.20.2. Simplest, catches every device. - dnsmasq is the DHCP server (config above) โ it already tells clients itself.
Never run two DHCP servers on one L2 segment; pick either the router or dnsmasq.
Why not override the whole zone?
address=/fogserv.cloud/192.168.20.10 would answer every subdomain with one IP. That breaks per-service routing unless one proxy fronts them all with host-based rules. Prefer explicit per-name address= lines so each service maps to its own proxy/back-end.
Practical Examples
Verify split-horizon behavior
dig +short grafana.fogserv.cloud @192.168.20.2
# 192.168.20.10 <- internal view
dig +short grafana.fogserv.cloud @1.1.1.1
# 104.21.x.y <- public Cloudflare edge
Confirm a DHCP lease
cat /var/lib/misc/dnsmasq.leases
# 1724640000 b8:27:eb:aa:bb:cc 192.168.20.30 rpi-cluster01 ff:...
Test from a laptop
curl -sI https://grafana.fogserv.cloud | head -1
# HTTP/2 200 โ served over LAN; cert still valid because it was issued publicly
Quick ad-blocking variant
Add conf-file=/etc/dnsmasq.d/blocklist where blocklist contains thousands of address=/doubleclick.net/ lines โ same mechanism as the service overrides.
Troubleshooting & Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Port 53 already in use | systemd-resolved stub listener | Disable resolved or set DNSStubListener=no, restart |
| Clients get IPs but no internet | Wrong router option / upstream dead | Check dhcp-option=option:router; test dig @1.1.1.1 from host |
| Double DHCP on segment | Router DHCP left enabled | Disable router DHCP or drop dnsmasq's dhcp-range |
| Internal HTTPS shows wrong cert | Wildcard missing / SNI mismatch | Issue wildcard via DNS-01; see letsencrypt article |
| DNS loop: dnsmasq forwards to itself | Upstream = own IP | no-resolv plus explicit external server= lines |
| New device name not resolving | Lease table vs address= mismatch |
Names come from config/leases; add explicit address= lines |
| Changes don't apply | Forgot restart or syntax error | dnsmasq --test then systemctl restart dnsmasq |
Next Steps / Ops Actions
- Keep the resolver reachable only from LAN segments: network-segmentation.
- Allow UDP/TCP 53 + 67 through ufw/nftables: ufw-firewall.
- Monitor resolver health with uptime checks: uptime-kuma-setup.
Sources & Related
External references consulted:
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation by KB build session.