DNS Stack — AdGuard + Unbound + dnsmasq
This guide covers setting up a layered DNS stack on OPNsense. Each service has a distinct role and they chain together so that all client DNS traffic is filtered, recursively resolved, and locally registered.
I am only using IPv4 for this guide. Not familiar enough with IPv6, and I don't feel like dealing with for now. I'll come around to it (maybe).
This is made for DNSMasq and Unbound DNS specifically, but same ideas apply if using something else. Same for omada controller (for the most part)
References
- OPNsense Dnsmasq Documentation
- OPNsense Unbound Documentation
- How to Migrate from ISC DHCP to dnsmasq or Kea DHCP in OPNsense
Architecture
Client
└─► AdGuard Home :53 — ad/tracker filtering, client-facing DNS
└─► Unbound :5335 — recursive resolver, DNSSEC, caching
└─► dnsmasq :53053 — local hostname resolution (.internal)
└─► Root DNS servers — all other queries
Purpose of each service:
- AdGuard — filtering layer. Sits in front so every query is inspected before resolution. Cannot do recursive resolution itself.
- Unbound — recursive resolver. Resolves public DNS from root servers directly (no third-party upstream needed). Forwards internal domain queries to dnsmasq.
- dnsmasq — DHCP server and local DNS registry. Registers DHCP hostnames automatically so
hostname.internalresolves without manual DNS entries. Cannot resolve public DNS itself — it needs Unbound as its upstream.
None of these roles overlap. Each is doing something the others cannot.
Step 1 — dnsmasq
Services → Dnsmasq DNS & DHCP → General
| Setting | Value | Why |
|---|---|---|
| Enable | Enabled | |
| Interface | All VLANs + LAN | Listen for DHCP on all networks |
| DNS Listen port | 53053 |
Non-standard port — Unbound forwards here, clients never query dnsmasq directly |
| DHCP FQDN | Enabled | Registers fully qualified names (e.g. host.internal) not just bare hostnames |
| DHCP default domain | internal |
Appended to all DHCP hostnames — server01 becomes server01.internal |
| DHCP local domain | Enabled | Makes dnsmasq authoritative for .internal — it will not forward these queries upstream |
| Do not forward to system defined DNS | Enabled | Prevents query loops — dnsmasq must not forward back to Unbound for local lookups |
Warning
DNS port must not be set to 0.** Port 0 disables dnsmasq DNS entirely. Unbound's Query Forwarding entries point to dnsmasq at port 53053 — if that port is 0, Unbound forwards .internal queries to nothing and all local hostname resolution silently breaks. The DNS port field must always be set to 53053 (or whatever non-zero port you chose). This is a common misconfiguration that produces confusing symptoms because DHCP continues to work while DNS fails.
Static Host Reservations
Services → Dnsmasq DNS & DHCP → Hosts
Add one entry per static host. A single entry handles both DHCP reservation and DNS registration.
| Field | Value |
|---|---|
| Host | Hostname only (e.g. server01) |
| Domain | internal |
| IP Address | Static IP (e.g. 10.10.30.20) |
| Hardware Address | Device MAC address |
This gives you three things from one entry:
- DHCP always assigns this IP to this MAC
server01resolves to the IPserver01.internalresolves to the IP
Tip
Use dnsmasq Hosts for DHCP-managed devices. Use Unbound Overrides only for devices with static IPs configured on the device itself (no DHCP), or for custom DNS aliases.
Tip
DHCP Static Reservation = Assigns a static IP to MAC address
DNS Override = Assigns a DNS address to an IP address.
The DNS record must match the DHCP Reservation if it is set, or it will not resolve.
DHCP Ranges
Services → Dnsmasq DNS & DHCP → DHCP ranges
Add one range per VLAN. Static reservations defined in Hosts sit outside the pool by MAC binding — they do not need to be excluded from the range manually.
Note
Reservations will reserve the IP address inside a range, meaning the reserved IP will not be offered to dynamic clients.
A dynamic range like 192.168.1.100-192.168.1.199 and a reservation like 192.168.1.101 are valid and there will be no collisions.
The reservation can also be outside the dynamic range, but it is not recommended for simple setups as the dynamic dns registration with dhcp-fqdn will not work correctly
| Field | Value |
|---|---|
| Interface | VLAN interface |
| Start | e.g. 10.10.30.100 |
| End | e.g. 10.10.30.200 |
DHCP Options — Push DNS to Clients
Services → Dnsmasq DNS & DHCP → DHCP options
Clients need to receive AdGuard's IP as their DNS server via DHCP. Add one entry per VLAN:
| Field | Value |
|---|---|
| Interface | VLAN interface |
| Option | 6 (DNS server) |
| Value | IP of OPNsense interface where AdGuard listens (e.g. 10.10.10.1) |
Tip
Use the gateway IP of the VLAN the client is on, not a fixed IP — once OPNsense has VLAN interfaces each will have its own IP and AdGuard listens on all of them.
DHCP Options — Omada Controller Discovery
If you are running Omada managed switches or APs on a dedicated MANAGEMENT VLAN, add an additional DHCP option on the MANAGEMENT interface so devices can locate the Omada controller after a reboot.
| Field | Value |
|---|---|
| Interface | MANAGEMENT |
| Option | option_capwap_ac_v4 [138] |
| Value | IP address of your Omada controller |
This is required because broadcast-based controller discovery does not work across VLANs. Without Option 138, Omada devices survive a single session but become orphaned after rebooting because they cannot find the controller on a different subnet.
Step 2 — Unbound
Services → Unbound DNS → General
| Setting | Value | Why |
|---|---|---|
| Enable | Enabled | |
| Listen Port | 5335 |
Non-standard port — AdGuard forwards here, clients never query Unbound directly |
| DHCP Domain Override | internal |
Tells Unbound which domain suffix to use when registering dnsmasq leases |
| Register DHCP Static Mappings | Enabled | Imports dnsmasq Host entries into Unbound's awareness |
| Register ISC DHCP4 Leases | Enabled | Registers active DHCP leases for dynamic hostname resolution |
| Flush DNS Cache during reload | Enabled | Clears stale records on every restart — important when IPs change |
| Local Zone Type | transparent |
Passes .internal queries through to dnsmasq rather than blocking them |
| Enable DNSSEC | Enabled | Validates DNS responses from upstream — Unbound is the right place to terminate DNSSEC |
Query Forwarding
Services → Unbound DNS → Query Forwarding
Warning
Unbound forwards .internal queries to dnsmasq instead of trying to resolve them recursively (which would fail — .internal is not a real TLD).
| Domain | Server IP | Port | Description |
|---|---|---|---|
internal |
127.0.0.1 |
53053 |
Forward local hostname lookups to dnsmasq |
x.x.x.in-addr.arpa |
127.0.0.1 |
53053 |
Forward reverse PTR lookups to dnsmasq |
Warning
Replace x.x.x with the reverse zone for your subnet. For 10.10.30.0/24 this is 30.10.10.in-addr.arpa. Add one PTR entry per subnet.
Without these entries, Unbound attempts to resolve hostname.internal from root DNS servers, fails, and returns NXDOMAIN.
These Query Forwarding entries must exist and must point to the correct port. If dnsmasq DNS is running on port
53053but the Query Forwarding entries are missing or point to a different port,.internalresolution will fail silently — DHCP will still work, but hostnames will not resolve.
Host Overrides
Services → Unbound DNS → Overrides
Use sparingly. Unbound Overrides are for:
- Devices with static IPs set on the device itself (not via DHCP) — dnsmasq never sees them
- Custom DNS aliases or CNAMEs (e.g.
omada.internal→prod-deb-01.internal) - Any record that needs to exist regardless of DHCP lease state
Do not duplicate entries that already exist as dnsmasq Hosts — they will conflict.
Step 3 — AdGuard Home
Services → Adguardhome
AdGuard sits in front of everything. Clients query it on port 53 (DNS port). It filters, then forwards upstream to Unbound on 5335.
Settings → DNS settings → Upstream DNS servers:
127.0.0.1:5335
This points AdGuard at Unbound. All queries — local and public — go through Unbound. Unbound then decides whether to forward to dnsmasq (.internal) or resolve recursively (everything else).
Settings → DNS settings → Bootstrap DNS:
1.1.1.1
9.9.9.9
Used only to resolve the upstream DNS server address itself at startup. Not used for client queries.
Settings → DNS settings → Clear cache — use this when stale records persist after configuration changes.
Step 4 — Force DNS via NAT
Prevents clients from bypassing AdGuard by pointing at a public resolver directly (e.g. 8.8.8.8).
Firewall → NAT → Port Forward — add one rule per VLAN:
| Field | Value |
|---|---|
| Interface | VLAN interface |
| Protocol | TCP/UDP |
| Source | VLAN net |
| Destination / Invert | Enabled — enter AdGuard IP |
| Destination Port | 53 |
| Redirect Target IP | AdGuard IP (OPNsense VLAN gateway) |
| Redirect Target Port | 53 |
| Description | Force DNS — [VLAN NAME] |
Inverting the destination means: redirect any DNS query NOT already going to AdGuard. Queries going to AdGuard pass through normally. Rogue queries get silently redirected.
Verification
After configuring all three services, verify each layer independently.
Test dnsmasq directly:
dig hostname.internal @<opnsense-ip> -p 53053
Test Unbound directly (bypasses AdGuard):
dig hostname.internal @<opnsense-ip> -p 5335
Test full chain through AdGuard:
dig hostname.internal @<opnsense-ip>
Test public resolution:
dig example.com @<opnsense-ip>
All four should return correct answers. If dnsmasq works but Unbound doesn't, the Query Forwarding entries are missing or wrong. If Unbound works but AdGuard doesn't, the upstream DNS setting in AdGuard is incorrect.
Troubleshooting AKA "It'll be better a different way and I break my fucking internet (again)"
NXDOMAIN for local hostnames
Check in order:
- Does the dnsmasq Host entry exist with correct MAC and IP?
- Is dnsmasq DNS listen port set to
53053(not0)? - Do the Unbound Query Forwarding entries point to
127.0.0.1:53053? - Is AdGuard upstream set to
127.0.0.1:5335?
DNS broke after editing dnsmasq settings
Check the DNS listen port field immediately. It is easy to accidentally set this to 0 while editing other settings. Port 0 disables dnsmasq DNS entirely, breaking the Unbound → dnsmasq chain and causing all .internal resolution to fail. Set it back to 53053 and restart dnsmasq.
Stale DNS record after IP change
Clear cache at every layer in order:
- AdGuard → Settings → DNS settings → Clear cache
- Restart Unbound (flushes cache on reload if enabled)
- Client:
sudo systemctl restart systemd-resolved(Linux) oripconfig /flushdns(Windows)
Client not receiving correct DNS via DHCP
Check Services → Dnsmasq DNS & DHCP → DHCP options — confirm option 6 is set for the client's VLAN interface with the correct AdGuard IP.
Verify what DNS the client actually received:
resolvectl status # Linux
ipconfig /all # Windows
Docker containers cannot resolve .internal hostnames
Docker containers use their own DNS configuration and do not automatically use the host's DNS settings. Add the VLAN gateway as an explicit DNS server in the container's compose file:
services:
myservice:
dns:
- 10.10.30.1 # VLAN gateway IP — replace with your LAB/SERVERS gateway
Without this, containers query Docker's internal DNS (127.0.0.11) which cannot resolve .internal hostnames.
Rogue DNS bypass not being caught
Verify NAT port forward rules exist for each VLAN under Firewall → NAT → Port Forward and that the destination invert is checked.