How to use your .lan names over Tailscale (with a tiny CoreDNS forwarder)

Updated:  /  Home Lab

Every service on my NAS has a short name on my home network: tasks.lan for my task manager, tv.lan for Jellyfin, docs.lan for my notes, and so on. AdGuard Home on my router answers those names, and Caddy on the NAS sends each one to the right container. At home, it all just works.

Away from home I use Tailscale. My router is a Tailscale subnet router, so I can reach every IP on my home network from my laptop or phone. But the names didn’t work. 192.168.8.103:3456 worked, tasks.lan didn’t, and I don’t want to remember port numbers. So about six months ago, I set out to fix it.

In this guide, I will show you how I made my .lan names work over Tailscale, with a tiny CoreDNS container and Tailscale’s Split DNS. I’ll also show you the router gotchas I ran into, because they cost me more time than the setup itself.

How it works

The idea is simple. When a device on my tailnet asks for any name ending in .lan, Tailscale sends that question to a DNS server on my home network. Everything else (google.com, my bank, etc.) goes to the device’s normal DNS, as before.

  1. My phone asks for tasks.lan.
  2. Tailscale’s Split DNS sends the question to 192.168.8.103 (my NAS), through the subnet router.
  3. A CoreDNS container on the NAS forwards it to AdGuard Home on the router.
  4. AdGuard answers with the NAS IP, and Caddy on the NAS does the rest.

You might be wondering why there’s a forwarder in the middle at all. Why not point Split DNS straight at the router? I’ll explain in step 3.

Here’s what we need to get started:

  • A Tailscale subnet router for your home network. In my case, it’s my GL.iNet GL-MT6000 router, advertising 192.168.8.0/24.
  • A DNS server at home that already answers your .lan names. Mine is AdGuard Home on the router, with one DNS rewrite per name.
  • An always-on machine with Docker for the forwarder. I use my NAS (a UGREEN DXP480T), which doesn’t run Tailscale itself. It’s reached through the subnet router like any other device at home.

1. Make sure the subnet route actually works

Before touching DNS, make sure you can reach your home network by IP over Tailscale. Otherwise you’ll be debugging DNS when the problem is routing.

On a GL.iNet router, I enabled Tailscale and advertised my home network like this (over SSH):

uci set tailscale.settings.enabled=1
/etc/init.d/tailscale restart
tailscale up --advertise-routes=192.168.8.0/24 --accept-dns=false

I use --accept-dns=false because the router is the DNS server for my home network. I don’t want Tailscale changing its DNS settings.

Next, approve the route in the Tailscale admin console: Machines, the ... menu next to your router, Edit route settings, then tick the subnet.

On my router, approving the route was not enough. GL’s firewall drops traffic coming from the tailnet into the home network unless the tailscale0 interface is part of the lan zone. This is the fix:

uci add_list firewall.@zone[0].device='tailscale0'
uci commit firewall
/etc/init.d/firewall reload

Now test it from a device on the tailnet, away from home (your phone on mobile data works well). Open http://192.168.8.103 or any IP from your home network. If it loads, the route works.

2. Create the CoreDNS forwarder

CoreDNS is a small DNS server that comes as a single Docker image. We only need it to do one thing: take every question and pass it on to AdGuard.

On the NAS, I created a folder /volume1/docker/dns-forwarder/ with two files. The first one is the Corefile:

# Forwards all queries (Tailscale Split DNS only sends *.lan here) to the
# router's AdGuard on :3053, which answers .lan rewrites for any source.
.:53 {
    forward . 192.168.8.1:3053
    cache 30
    loop
    log
    errors
}

AdGuard Home on my router listens on port 3053, because the router’s own dnsmasq owns port 53. Replace 192.168.8.1:3053 with the address of your own DNS server. The cache 30 line keeps answers for 30 seconds, and loop stops CoreDNS if it ever ends up asking itself.

The second file is the docker-compose.yaml:

services:
  dns-forwarder:
    image: coredns/coredns:latest
    container_name: dns-forwarder
    restart: unless-stopped
    command: -conf /Corefile
    volumes:
      - ./Corefile:/Corefile:ro
    ports:
      - "192.168.8.103:53:53/udp"
      - "192.168.8.103:53:53/tcp"

Notice that the ports are published on the NAS IP (192.168.8.103), not on all interfaces. I did that on purpose, so it doesn’t clash with other DNS resolvers on the NAS. If you copy this, replace it with your own machine’s IP.

Start it with docker compose up -d, and test it from any computer at home:

dig @192.168.8.103 tasks.lan +short

It should look like this (your NAS IP):

192.168.8.103

3. Point Tailscale’s Split DNS at the forwarder

In the Tailscale admin console, go to DNS → Nameservers → Add nameserver → Custom. Enter the IP of the machine running CoreDNS (192.168.8.103 in my case), turn on Restrict to domain and type lan. This is what makes it “split”: only .lan names go to your forwarder.

While you’re on the DNS page, make sure MagicDNS is on. Mine is.

Now, why not just enter the router’s IP here and skip the forwarder? There are multiple reasons:

  • The router’s dnsmasq answers .lan names only for devices on the home network. Questions coming from tailnet IPs (100.x.x.x) get NXDOMAIN (“this name doesn’t exist”).
  • Fixing that on the router means restarting dnsmasq, and on my router that briefly takes down DNS for the whole house. It also races with Tailscale for port 53 when it restarts.
  • AdGuard itself (on port 3053) answers .lan for anyone, but the Split DNS form takes only an IP address, with no port.

So instead of fighting the router, I left it alone and put a forwarder on the NAS. It’s one small container, and the router doesn’t even know it’s there.

4. Test it

Turn off Wi-Fi on your phone (so you’re on mobile data, with Tailscale on) and open one of your .lan names in the browser. I did exactly that, and my .lan pages loaded just like at home.

On a laptop, you can test it with dig against Tailscale’s resolver:

dig @100.100.100.100 tasks.lan +short

It should give you the same NAS IP as before. Also check the case that should not change: a public name like marius.digital should still resolve exactly as it did before, through your normal DNS.

That’s it! You have now made your .lan names work from anywhere on your tailnet. Adding a new service later doesn’t need anything on the forwarder: I only add the DNS rewrite in AdGuard, and the name works at home and over Tailscale at once.

The gotcha that broke it for two days

In October, my .lan names stopped working over Tailscale, while everything worked fine at home. The forwarder container was “running”, but docker inspect showed it had no network at all (Networks: {}), and its logs said network is unreachable.

The cause was the boot order on my NAS. At one boot, Docker started before the NAS had brought up its 192.168.8.103 address. Docker couldn’t bind port 53 on an IP that didn’t exist yet, and on the next boots the container came back without its network.

The quick fix was recreating the container:

cd /volume1/docker/dns-forwarder && docker compose up -d --force-recreate

The real fix was one kernel setting, which lets a program bind to an IP before the IP is up:

echo 'net.ipv4.ip_nonlocal_bind=1' | sudo tee /etc/sysctl.d/60-nonlocal-bind.conf
sudo sysctl --system

If you publish ports on a specific IP (like I do here), I highly recommend setting this. It hit another container of mine the same way. I also added a DNS monitor in Uptime Kuma that asks the forwarder for a name, so I get a Telegram message if it stops answering, instead of finding out when I’m away from home.

Closing remarks

  • There’s no wildcard. My router’s dnsmasq can’t do *.lan without breaking router.lan and every device name it hands out, so every service needs its own rewrite in AdGuard. It’s one extra line per service, which I can live with.
  • The router is still the single point of failure. My GL-MT6000 has 1 GB of RAM and no swap. Twice this summer, AdGuard Home ran out of memory and took all DNS down with it (a very large blocklist was one of the causes). If you run AdGuard on a router, check memory before adding a big filter list.
  • GL.iNet turns on the exit node by itself. The stock GL startup script runs tailscale up --advertise-exit-node every time Tailscale starts, whatever the setting in the UI says. I removed the flag from /usr/bin/gl_tailscale and ran tailscale set --advertise-exit-node=false. A firmware update puts the original script back, so I check it after every update.
  • .lan is not an official top-level domain, and it’s not reserved either. It works fine for a home network, but if you’re starting from scratch, .home.arpa and .internal are both reserved for private networks, so they’re the safer choice.

If you have a simpler way to do this on a GL.iNet router, let me know. I’d be happy to drop a container.