skip to content
BitsAndBytes
Table of Contents

In part 1 I set up Cloudflare DDNS and UniFi’s Alternate Address so my WireGuard clients connect to vpn.huislab.app instead of a hardcoded IP. Phone on cellular: handshake in seconds. Job done, I thought.

Then I tried the travel router. Same fresh config, same hostname — stuck on “connecting” forever. No handshake, no error, nothing.

The only difference: the travel router was sitting at home, on my own Wi-Fi.

This post is the rabbit hole that followed. Spoiler: nothing was broken. Two things were each doing exactly what I’d told them to do — they just disagreed about what vpn.huislab.app means.

The one-line diagnosis

When a hostname-based connection hangs, the first question is always: what does that hostname actually resolve to from where the client is standing?

Terminal window
$ dig vpn.huislab.app +short
192.168.1.155

Wait, what? That’s not my WAN IP. That’s my internal reverse proxy — the box running Nginx Proxy Manager. And for comparison, what does the rest of the world see?

Terminal window
$ dig @1.1.1.1 vpn.huislab.app +short
203.0.113.10 # my actual WAN IP — correct

Same name, two completely different answers depending on who you ask. Cloudflare (public) says my WAN IP, my own resolver (internal) says the proxy box. The travel router, sitting at home, asked the internal resolver — got the proxy — and dutifully fired WireGuard handshakes at a box that has no idea what WireGuard is. Hence: “connecting” until the heat death of the universe.

This pattern has a name: split-horizon DNS. One domain, two views — an internal one and a public one. I’d been running it for ages without ever calling it that.

flowchart TB
Q["dig vpn.huislab.app<br/><span style='font-size:0.8em'>same name, asked from two places</span>"]

subgraph home["🏠 asked from inside the house"]
direction TB
AG["AdGuard · rewrite *.huislab.app → .155"]
PROXY["💀 192.168.1.155 · reverse proxy<br/><span style='font-size:0.8em'>no idea what WireGuard is → 'connecting…' forever</span>"]
AG -->|"wildcard swallows vpn"| PROXY
end

subgraph world["🌐 asked from anywhere else"]
direction TB
CF["Cloudflare · public DNS"]
WAN["✅ 203.0.113.10 · real WAN IP<br/><span style='font-size:0.8em'>handshake lands</span>"]
CF --> WAN
end

Q -.->|"internal resolver"| home
Q ==>|"public resolver"| world

classDef bad stroke:#e06c75,stroke-width:2px,stroke-dasharray:6 4,fill:transparent;
classDef good stroke:#98c379,stroke-width:2px,fill:transparent;
class home bad;
class world good;

How I’d built this trap for myself

Like a lot of homelabs, mine runs everything behind a wildcard. In AdGuard Home (my network-wide DNS), there was a single DNS rewrite:

*.huislab.app → 192.168.1.155

Every internal service name — hass.huislab.app, grafana.huislab.app, z2m.huislab.app — resolves to the reverse proxy, NPM routes by hostname, TLS via Cloudflare DNS challenges, lovely. None of these names exist in public DNS, which is deliberate: internal service names don’t need to be advertised to the internet.

The problem: a wildcard doesn’t know which names you meant. The moment I created vpn.huislab.app as a genuinely public name (part 1), the wildcard swallowed it internally. Outside the house, Cloudflare answered with the WAN IP. Inside, AdGuard answered with the proxy. Both technically correct. One of them useless.

The phone had been masking this the whole time: its config predated the hostname change and still carried the literal WAN IP as its endpoint. Connecting by IP doesn’t ask DNS anything — so it worked everywhere, and the broken internal view stayed invisible until the first client actually used the hostname from inside.

The quick fix (and its expiry date)

AdGuard resolves the most specific rewrite first, so adding an exact entry above the wildcard works:

vpn.huislab.app → 203.0.113.10

Internal clients now get the WAN IP, hairpin NAT loops the traffic back through the gateway, handshake lands. (Worth knowing: hairpin NAT — reaching your own public IP from inside — works out of the box on UniFi gateways. Not every router does this.)

Tested, worked, travel router connected. Done?

Not really. Look at that entry again: it’s a static IP — the exact thing part 1 was about eliminating. Cloudflare’s record self-updates via DDNS when my ISP rotates the address. This AdGuard entry doesn’t. The day the IP changes, remote clients heal automatically and everyone at home silently points at a dead address. I’d have rebuilt the original problem, but only for half my network, and in the half where I’d take longest to notice.

The detour: automating the wrong thing

My first instinct was the classic homelab move: script it. A cron job on the AdGuard VM that asks 1.1.1.1 what vpn.huislab.app currently resolves to (Cloudflare is the source of truth — DDNS already maintains it) and syncs AdGuard’s rewrite through its API whenever they drift. I actually wrote it. It would have worked fine.

But it nagged at me. The script existed only to paper over a missing feature: what I actually wanted to tell my resolver was

“wildcard everything under huislab.appexcept vpn, which should resolve normally.”

And AdGuard Home simply cannot express that:

  • Its wildcards are multi-level*.huislab.app matches vpn.huislab.app but also vpn.ext.huislab.app, anything.deeper.huislab.app, all the way down. You can’t escape a wildcard by nesting the name deeper. (I checked. Standard DNS wildcard semantics work the same way.)
  • Rewrites can only add answers. There’s no rewrite that means “don’t rewrite this one, go upstream.” Wildcard exceptions have been an open AdGuard feature request since 2020.

So the choice was: keep a cron job forever compensating for my resolver, or use a resolver that can express the rule. Easy call.

Unbound behind AdGuard

Unbound is a small, battle-hardened recursive DNS resolver. No GUI, no ad-blocking — just a resolver with a precise configuration language. And its local-zone model has exactly the precedence behaviour AdGuard lacks.

The architecture: AdGuard stays the front door — clients keep pointing at it, it keeps doing filtering, per-client stats, and the dashboard. But its only upstream becomes a local Unbound instance, which owns all the huislab.app zone logic and does full DNSSEC-validating recursion for everything else.

clients → AdGuard (filtering) → Unbound (zone logic + recursion) → the internet

The entire split-horizon configuration is this:

# Wildcard: every name under huislab.app -> the reverse proxy
local-zone: "huislab.app." redirect
local-data: "huislab.app. IN A 192.168.1.155"
# Exception: vpn resolves normally -> Cloudflare -> current WAN IP
local-zone: "vpn.huislab.app." transparent

That’s it. redirect answers every name in the zone with the proxy address — the wildcard. transparent with no local data means “I know nothing about this name, resolve it upstream like any other.” Unbound always matches the most specific zone, so the vpn exception beats the wildcard. The exact rule AdGuard couldn’t express, in four lines.

flowchart TB
IN["query for *.huislab.app"]
IN --> MATCH{"most-specific<br/>local-zone match"}

MATCH -->|"vpn / blog<br/>transparent"| REC["recurse upstream<br/><span style='font-size:0.8em'>→ Cloudflare → current WAN IP / blog</span>"]
MATCH -->|"everything else<br/>redirect"| WILD["192.168.1.155<br/><span style='font-size:0.8em'>reverse proxy — the wildcard</span>"]

classDef good stroke:#98c379,stroke-width:2px,fill:transparent;
classDef neutral stroke:#5c6370,stroke-width:1px,fill:transparent;
class REC good;
class WILD neutral;

And the part that kills the sync script for good: the exception doesn’t contain an IP at all. Unbound recurses out and gets whatever Cloudflare’s DDNS-maintained record currently says, honouring its ~5-minute TTL. When my ISP rotates the IP, both horizons now self-heal — remote clients via public DNS, home clients via Unbound’s recursion. Nothing to maintain.

Install and cutover

On the AdGuard VM (Debian-based; AdGuard runs in Docker there):

Terminal window
sudo apt update && sudo apt install -y unbound

Drop the config into /etc/unbound/unbound.conf.d/huislab.conf. Beyond the four zone lines above, mine binds to the VM’s LAN IP on port 5335 (AdGuard owns :53 ), allows queries from localhost + RFC1918 (which covers Docker’s bridge networks — relevant, since a containerised AdGuard reaches Unbound via the host IP, not loopback), disables IPv6 recursion (my ISP gives me none), and adds the usual hardening: qname-minimisation, harden-dnssec-stripped, prefetch. The Debian package wires up the DNSSEC trust anchor on its own.

Terminal window
sudo unbound-checkconf && sudo systemctl restart unbound && sudo systemctl enable unbound

Test Unbound directly before touching AdGuard — this is the no-downtime trick. Cut over only after the new layer demonstrably works:

Terminal window
dig @127.0.0.1 -p 5335 hass.huislab.app +short # → 192.168.1.155 (wildcard)
dig @127.0.0.1 -p 5335 vpn.huislab.app +short # → my WAN IP (exception!)
dig @127.0.0.1 -p 5335 google.com +short # → normal answer (recursion)

Three correct answers. Then the cutover, in order:

  1. AdGuard → Settings → DNS settings → Upstream DNS servers: replace everything with the single line 192.168.1.155:5335. (My fallback servers — 9.9.9.9 and 1.1.1.1 — stay. If Unbound ever dies, the internet keeps resolving and only the internal names go dark until I fix it. Best available failure mode.)
  2. Filters → DNS rewrites: delete both the wildcard and the static vpn entry. This is the step that actually hands control to Unbound — AdGuard answers rewrites locally and never forwards them, so any rewrite left behind keeps intercepting queries before Unbound ever sees them.
  3. Clear AdGuard’s cache, then verify from a normal client.

I learned step 2’s importance the slightly embarrassing way: after switching the upstream, my test digs all passed — and meant nothing, because the rewrites were still answering from AdGuard. Right results, wrong layer. Delete the rewrites, then trust the digs.

The bonus find: my own blog

With the pattern in place, one more thing fell out of the audit. My blog (blog.huislab.app) runs on Cloudflare Workers — a genuinely public name, just like vpn. Which means the wildcard had been hijacking it internally this whole time:

Terminal window
$ dig blog.huislab.app +short
192.168.1.155 # my proxy — which knows nothing about the blog
$ dig @1.1.1.1 blog.huislab.app +short
188.114.96.0 # Cloudflare — the actual blog

From inside my own house, my own blog didn’t resolve to my blog. The fix is the now familiar one-liner:

local-zone: "blog.huislab.app." transparent

Restart Unbound, and inside and outside finally agree. That’s the real win of this design — it’s a pattern, not a patch. Any future public name under the domain is one transparent line away.

Where this leaves the DNS stack

flowchart TB
CLIENTS["📱 clients<br/><span style='font-size:0.8em'>DHCP-advertised resolver</span>"]

subgraph vm["🏠 AdGuard VM"]
direction TB
AG["AdGuard Home<br/><span style='font-size:0.8em'>filtering · per-client stats · :53</span>"]
subgraph ub["Unbound · :5335 · owns huislab.app"]
direction TB
Z1["redirect — *.huislab.app<br/><span style='font-size:0.8em'>→ 192.168.1.155 reverse proxy</span>"]
Z2["transparent — vpn / blog<br/><span style='font-size:0.8em'>fall through to recursion</span>"]
end
AG ==>|"single upstream · 192.168.1.155:5335"| ub
end

CLIENTS --> AG
Z2 ==>|"DNSSEC recursion"| NET["🌐 the internet · Cloudflare"]
AG -.->|"fallback if Unbound dies · 9.9.9.9 · 1.1.1.1"| NET

classDef box fill:transparent,stroke:#5c6370,stroke-width:1px;
classDef good stroke:#98c379,stroke-width:2px,fill:transparent;
class vm,ub box;
class Z2 good;
  • AdGuard Home — filtering, per-client stats, DHCP-advertised resolver. Knows nothing about huislab.app anymore. Single upstream: Unbound. Fallback: public resolvers.
  • Unbound — owns the zone: wildcard internal names → reverse proxy; vpn and blog fall through to public resolution; DNSSEC-validating recursion for the rest.
  • Cloudflare DDNS — keeps vpn.huislab.app current; both horizons follow it automatically.

No static entries, no sync script, nothing that goes stale when the ISP rotates my IP. The travel router connects from the couch and from a hotspot with the same config — which was the whole point.

TL;DR

  • If a hostname works remotely but not at home, compare dig name +short against dig @1.1.1.1 name +short. Different answers = split-horizon DNS, and your internal view is the suspect.
  • Wildcard DNS rewrites are multi-level and swallow every name under the domain — including the public ones you add later. You can’t nest your way out of one.
  • AdGuard Home can’t do wildcard exceptions (rewrites only add answers; the feature request is years old). Don’t script around a missing primitive — put Unbound behind AdGuard and express the rule properly.
  • local-zone redirect = wildcard; a more-specific local-zone transparent = “resolve this one normally.” Most-specific zone wins.
  • An exception that recurses instead of pinning an IP makes the internal view self-healing — it always returns whatever DDNS currently publishes.
  • Cut over safely: stand up Unbound on an alt port, test it directly, switch AdGuard’s upstream, then delete the old rewrites — and re-test, because leftover rewrites make your verification lie to you.