DNS Deploy Role¶
Deploy Unbound DNS with split-horizon views and ad blocking for homelab environments.
Features¶
Split-horizon DNS: Different responses for LAN vs Tailscale clients
Ad blocking: Via customizable blocklists (hosts and AdGuard syntax)
Wildcard DNS: For Traefik-backed services (
*.home.example.com)Custom DNS entries: Service-specific overrides
IDN/Punycode support: For domains with special characters
Automatic blocklist updates: Via systemd timer
DNSSEC validation: For secure DNS resolution
Dynamic DNS (DDNS): Optional automatic updates for Gandi LiveDNS (opt-in)
Requirements¶
Ubuntu 20.04+ or Debian 10+
Root or sudo access
Network connectivity for package installation
Quick Start¶
- hosts: dns_server
become: true
roles:
- role: local.ops_library.dns_deploy
vars:
dns_local_domain: "home.example.com"
dns_local_domain_punycode: "home.example.com" # Same if no special chars
# Split-DNS configuration
dns_split_lan_network: "192.168.1.0/24"
dns_split_tailscale_network: "100.64.0.0/10"
dns_split_lan_ip: "192.168.1.5"
dns_split_tailscale_ip: "100.64.0.5"
# Service overrides (optional)
dns_split_services:
- { name: "router", lan_ip: "192.168.1.1", tailscale_ip: "192.168.1.1" }
- { name: "nas", lan_ip: "192.168.1.10", tailscale_ip: "192.168.1.10" }
Architecture¶
LAN Client (192.168.x.x) → Unbound (port 53) → LAN view → Returns LAN IP
Tailscale Client (100.x.x.x) → Unbound (port 53) → Tailscale view → Returns Tailscale IP
Unbound handles everything:
- Split-DNS views based on source IP
- Ad blocking via blocklists
- Wildcard DNS for *.home.example.com
- Custom host overrides
- Recursive resolution with DNSSEC
Role Variables¶
Core Configuration¶
Variable |
Default |
Description |
|---|---|---|
|
|
Local domain for services |
|
|
IDN encoded version |
|
|
IP for wildcard resolution |
Unbound Behavior¶
Variable |
Default |
Description |
|---|---|---|
|
|
Serve stale cached answers during upstream outages |
|
|
Maximum age of stale entries served to clients |
|
|
TTL returned to clients for stale answers |
|
|
Keep stale entries eligible during repeated upstream refresh failures |
|
|
Optional RFC 8767 delay before replying with stale data |
|
|
Refresh hot cache entries before expiry to reduce outage misses |
|
|
Prefetch DNSKEY material to reduce DNSSEC latency for hot names |
|
|
Override recursion queue depth per thread (0 keeps Unbound’s default) |
|
|
Disable Ubuntu’s Unbound/resolvconf helper when the role manages |
When dns_unbound_serve_expired is enabled, Unbound can keep answering from cache during brief upstream failures instead of immediately returning SERVFAIL for expired records. This is still partial protection: uncached names, newer RR types such as HTTPS, or overloaded recursion queues can still fail during a WAN outage. For outage-prone links, pair stale serving with dns_unbound_prefetch, dns_unbound_serve_expired_ttl_reset, and a larger dns_unbound_num_queries_per_thread.
Split-DNS Configuration¶
Variable |
Default |
Description |
|---|---|---|
|
|
LAN network range |
|
|
Tailscale network range |
|
|
Optional LAN IPv6 ranges (e.g., |
|
|
Optional Tailscale IPv6 ranges (e.g., |
|
|
IP returned for LAN clients |
|
|
IP returned for Tailscale clients |
|
|
Service-specific IP overrides |
Blocklist Configuration¶
Variable |
Default |
Description |
|---|---|---|
|
|
Enable ad blocking |
|
See defaults |
List of blocklist URLs |
|
|
Domains never to block |
Supported upstream blocklist formats are hosts-style entries (for example 0.0.0.0 example.com) and AdGuard-style rules (for example ||example.com^). Plain one-domain-per-line feeds are not parsed by the generated updater script.
Forward Zones Configuration¶
Forward zones allow specific domains to be resolved by designated upstream DNS servers. This is useful for local domains (e.g., fritz.box) that need resolution by a local router or appliance.
Variable |
Default |
Description |
|---|---|---|
|
|
List of forward zone configurations |
Each forward zone entry supports:
Key |
Required |
Description |
|---|---|---|
|
Yes |
Domain to forward (e.g., |
|
Yes |
List of upstream DNS server IPs |
|
No |
Try forward first, fall back to local resolution on failure (default: |
Example - Forward fritz.box to local router:
dns_forward_zones:
- name: "fritz.box"
forward_addrs:
- "192.168.178.1"
Example - Multiple forwarders for redundancy:
dns_forward_zones:
- name: "corp.example.com"
forward_addrs:
- "10.0.0.53"
- "10.0.0.54"
forward_first: true
Example - Forward all other queries to public resolvers:
dns_forward_zones:
- name: "."
forward_addrs:
- "1.1.1.1"
- "1.0.0.1"
- "8.8.8.8"
- "8.8.4.4"
forward_first: true
- name: "fritz.box"
forward_addrs:
- "192.168.178.1"
For the root zone ., forward_first: true lets Unbound fall back to direct recursion if the configured forwarders are unavailable but general internet connectivity still works. Keep forward_first: false for private-only forwarded zones like fritz.box or tailde2ec.ts.net.
Example - LAN/Tailscale IPv6 view mapping:
dns_split_lan_ipv6_networks:
- "fd50:3e45:c454::/64"
dns_split_tailscale_ipv6_networks:
- "fd7a:115c:a1e0::/48"
Local DNS Records¶
Local records are served authoritatively by Unbound before forwarded zones. Use them for critical hostnames inside forwarded domains when router or DHCP registration is unreliable.
Variable |
Default |
Description |
|---|---|---|
|
|
List of static DNS records served directly by Unbound |
Each local record entry supports:
Key |
Required |
Description |
|---|---|---|
|
Yes |
Fully-qualified hostname (e.g., |
|
Yes |
DNS record type (e.g., |
|
Yes |
Record value |
Example - Pin a critical host inside a forwarded router zone:
dns_local_records:
- name: "macmini.fritz.box"
type: "A"
value: "192.168.178.94"
Notes:
Use leaf hostnames like
macmini.fritz.box, not zone apex names likefritz.box.A local record whose
nameexactly matches a forwarded zone apex would shadow the forwarded zone.Supported record types are
A,AAAA,CNAME,TXT,MX,PTR, andSRV.
Local Resolver Management¶
Variable |
Default |
Description |
|---|---|---|
|
|
Rewrite |
|
|
Ordered list of nameservers to write into |
|
|
Optional search domains appended to |
|
|
Resolver options line |
When
dns_update_resolv_confis enabled (default), the role stopssystemd-resolvedand installs a static/etc/resolv.confpointing at Unbound. This prevents Ubuntu upgrades from recreating a broken stub resolver.
DDNS Configuration (Optional)¶
Variable |
Default |
Description |
|---|---|---|
|
|
Enable dynamic DNS updater |
|
|
DNS provider (only Gandi supported) |
|
|
Base domain (e.g., |
|
|
Subdomain to update |
|
|
Also update |
|
|
DNS record TTL in seconds |
|
|
Update check interval |
|
|
Gandi LiveDNS API token |
|
|
Update IPv4 (A records) |
|
|
Update IPv6 (AAAA records) |
|
|
Log rotation size in MB |
How It Works¶
Split-DNS Views¶
Unbound uses view-based configuration to return different IPs based on client source:
LAN clients (192.168.x.x) get LAN IPs
Tailscale clients (100.x.x.x) get Tailscale IPs
Service overrides take precedence over wildcards
Ad Blocking¶
Blocklists are downloaded and converted to Unbound format
Systemd timer updates blocklists daily
Allowlist ensures critical domains are never blocked
Mixed hosts-style and AdGuard-style lists are accepted, and one failed download no longer aborts the whole refresh
Wildcard Resolution¶
*.home.example.com→ Configured IP (LAN or Tailscale based on view)Specific services can override with
dns_split_services
Dynamic DNS (Optional)¶
When enabled (dns_ddns_enabled: true):
Detects current public IPv4 and IPv6 addresses
Updates Gandi LiveDNS via API when IP changes
Updates both base domain and wildcard (e.g.,
home.example.comand*.home.example.com)Runs automatically via systemd timer (default: every 5 minutes)
Logs all updates to
/var/log/ddns/ddns-update.logRuns as dedicated
ddnsservice account
Example configuration:
- role: local.ops_library.dns_deploy
vars:
# ... other DNS vars ...
# Enable DDNS
dns_ddns_enabled: true
dns_ddns_domain: "example.com"
dns_ddns_subdomain: "home"
dns_ddns_api_token: "{{ vault_gandi_api_token }}" # From vault/secrets
Testing¶
# From LAN client
dig @192.168.1.5 deploy.home.example.com
# Returns: 192.168.1.5
# From Tailscale client
dig @100.64.0.5 deploy.home.example.com
# Returns: 100.64.0.5
# Test ad blocking
dig @192.168.1.5 doubleclick.net
# Returns: 0.0.0.0 or NXDOMAIN
# Test DNSSEC
dig @192.168.1.5 google.com +dnssec
# Should show AD flag for authenticated data
# Inspect stale-serving and recursion headroom after an upstream outage
sudo unbound-control stats_noreset | egrep 'total.num.expired|total.requestlist.max|total.requestlist.exceeded'
# Useful when clients reported SERVFAIL during a WAN reconnect
# Test DDNS (when enabled)
sudo systemctl status ddns-update.timer
# Should be active and show next trigger time
sudo journalctl -u ddns-update.service -n 50
# View DDNS update logs
# Check DDNS log file
sudo tail -f /var/log/ddns/ddns-update.log
# Shows IP detection and DNS update activity
# Manually trigger DDNS update
sudo systemctl start ddns-update.service
# Force an immediate update check
Tailscale Integration¶
For Tailscale clients to use this DNS:
Access Tailscale Admin Console
Go to DNS settings
Enable Magic DNS
Add nameserver: Your Tailscale IP (e.g.,
100.64.0.5)Restrict to domain: Your punycode domain (e.g.,
home.xn--example-abc.de)
Troubleshooting¶
Wrong IP returned¶
Check source network matches configured ranges
Verify
dns_split_tailscale_ipis set correctlyRestart Unbound:
sudo systemctl restart unbound
Domains not blocked¶
Check blocklist download:
sudo systemctl status unbound-blocklist.serviceVerify domain not in allowlist
Force update:
sudo systemctl start unbound-blocklist.service
Brief WAN outages still cause some SERVFAILs¶
Check whether the failing name was already cached:
sudo unbound-control dump_cache | rg 'example.com'Inspect stale-serving and queue stats:
sudo unbound-control stats_noreset | egrep 'total.num.expired|total.requestlist.exceeded'Remember that
serve-expiredonly helps for cached data; uncached names and someHTTPS/SVCBlookups can still fail during DSL reconnectsEnable
dns_unbound_prefetch,dns_unbound_serve_expired_ttl_reset, and tunedns_unbound_num_queries_per_threadon outage-prone links
Service not resolving¶
Check Unbound config:
sudo unbound-checkconfView logs:
sudo journalctl -u unbound -fTest from server:
dig @127.0.0.1 test.home.example.comEnsure
/etc/resolv.confpoints to127.0.0.1(rerun the role withdns_update_resolv_conf: trueif it was overwritten)
DDNS not updating¶
Check timer status:
sudo systemctl status ddns-update.timerView update logs:
sudo tail /var/log/ddns/ddns-update.logCheck service errors:
sudo journalctl -u ddns-update.service -n 100Verify API token:
sudo cat /etc/ddns/gandi.env(check permissions)Test connectivity:
curl -H "Authorization: Bearer YOUR_TOKEN" https://api.gandi.net/v5/livedns/domains/example.comManual trigger:
sudo systemctl start ddns-update.service
DDNS updates but DNS not resolving¶
Verify records in Gandi: Check domain control panel
Check TTL: Wait for TTL expiry (default 300 seconds)
Test with public DNS:
dig @8.8.8.8 home.example.comVerify subdomain matches: Check
dns_ddns_subdomainvariable
Files and Directories¶
DNS (Unbound)¶
/etc/unbound/unbound.conf.d/- Unbound configuration00-base.conf- Base Unbound settings05-forward-zones.conf- Forward zone configuration (whendns_forward_zonesis set)10-split-dns.conf- Split-horizon views
/etc/default/unbound- Distro helper toggles; the role disables resolvconf integration here by default/var/lib/unbound/- Blocklists and data/usr/local/bin/update-blocklists.sh- Blocklist update script/etc/systemd/system/unbound-blocklist.*- Update timer and service
DDNS (when enabled)¶
/usr/local/bin/ddns-update.sh- DDNS update script/etc/ddns/gandi.env- Gandi API token (0600 permissions)/var/log/ddns/ddns-update.log- Update logs/etc/systemd/system/ddns-update.service- DDNS service unit/etc/systemd/system/ddns-update.timer- DDNS timer unit
License¶
MIT