I run AdGuard Home on my tailnet so every device gets filtering no matter whose network it sits on. The tailnet DNS setting points at it, override local DNS is on, and ads die at the resolver. That worked until I asked what happens when the resolver itself dies.

The obvious answer is listing two resolvers. A thread about two Pi-holes shows why that breaks: both set as global nameservers, one goes down, and the phone loses DNS instead of using the other. The replies point at an open Tailscale bug about fallback to alternate nameservers on Apple clients, still open with fresh reports. I tested the good case on Linux (instant failover) and decided correctness should not depend on which client OS asks.

So I run one shared service address across my resolvers instead, and the tailnet lists only that. Every resolver binds the address and advertises it as a subnet route; Tailscale sends the traffic to one of them and moves it when that one disappears. Clients see a single resolver that survives dead boxes. The classic version of this is keepalived with a floating IP, but that needs every box on the same LAN. Mine sit in different houses, so I built the same shape inside Tailscale. It works with AdGuard Home, Pi-hole, or any DNS server.

Put the address on every resolver

Add the service address to loopback on each DNS box. I use 10.99.0.53; pick something nothing else uses, it must stay stable forever:

ip addr add 10.99.0.53/32 dev lo

Keep it across reboots with a oneshot systemd unit (/etc/systemd/system/dns-service-ip.service):

[Unit]
Description=DNS service address
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c "/sbin/ip addr add 10.99.0.53/32 dev lo 2>/dev/null || true"
ExecStop=/bin/sh -c "/sbin/ip addr del 10.99.0.53/32 dev lo 2>/dev/null || true"

[Install]
WantedBy=multi-user.target

DNS servers answer on all interfaces by default, so they serve the new address with no extra config. Check with dig @10.99.0.53 example.com on each box before continuing.

Run Tailscale on each resolver advertising that single address:

tailscale up --advertise-routes=10.99.0.53/32 --accept-dns=false

--accept-dns=false matters. A resolver must never resolve through the address it helps serve, or an outage turns into a loop.

Approve the routes in the admin console under Machines. Nodes advertising the identical /32 form a failover group: Tailscale sends traffic to one of them and moves it when that one disappears. That is active-passive high availability: one serves, the rest wait. The address moves in about 15 seconds when the serving node drops.

One DNS address served by three resolvers

Two ACL entries are needed, one so members can reach the address, one so future replicas self-approve the route:

{
  "action": "accept",
  "src": ["autogroup:member"],
  "dst": ["10.99.0.53:*"]
}
"autoApprovers": {
  "routes": {
    "10.99.0.53/32": ["your@email.com"]
  }
}

Point tailnet DNS at the address

Admin console, DNS page: set 10.99.0.53 as the only global nameserver and turn on override local DNS. Leave MagicDNS on; short names keep working alongside it. Phones need subnet routes enabled in the Tailscale app or they cannot reach the address.

Clients see one resolver. Failover happens underneath it, in route selection, where every client behaves the same.

When the app dies and the box does not

Route failover watches the machine. If the DNS process dies while its host stays up, the route stays and queries land on a dead port. Each of my boxes runs this check every 30 seconds from a systemd timer (/usr/local/bin/dns-vip-watch.sh):

#!/bin/bash
FAILS=/run/dns-vip-fails
DOWN=/run/dns-vip-down

if dig +short +time=3 +tries=1 @127.0.0.1 example.com 2>/dev/null | grep -qE '^[0-9a-fA-F:.]+$'; then
  rm -f "$FAILS"
  if [ -f "$DOWN" ]; then
    tailscale up --advertise-routes=10.99.0.53/32 --accept-dns=false --accept-routes=false
    rm -f "$DOWN"
  fi
  exit 0
fi

n=$(cat "$FAILS" 2>/dev/null || echo 0); n=$((n + 1)); echo "$n" > "$FAILS"
if [ "$n" -lt 3 ]; then exit 0; fi
if [ ! -f "$DOWN" ]; then
  touch "$DOWN"
  tailscale down
fi

Three misses in a row and the box leaves the tailnet, which withdraws its route and hands the address to a survivor. When the app recovers, the box rejoins on its own.

What happens when the DNS app dies

Killing only the app took around 90 seconds end to end here, most of it the debounce before the box steps down. I kept the debounce; a hair-trigger step-down would flap on shaky links.

Keep the resolvers identical

Failover only helps if every answer matches. I run adguardhome-sync on one node, which copies filters, user rules, DNS settings, rewrites, and clients to the others every six hours. Pi-hole users do the same job with Gravity Sync. Check rule counts after syncing: a blocklist URL once started serving a different list that parsed to zero rules, and nothing complained. My block test list (128 ad and telemetry hostnames) resolves blocked on every node.

Upstreams deserve a measurement per site rather than a guess. Fourteen providers, five samples each, from every box: two of my sites see all anycast providers at 0 to 2 ms, the third sees 13 to 24 ms with one provider at 200 ms, which got dropped. My resolvers race the survivors per query, so I picked a set that is fast from every site instead of one winner, all unfiltered, with blocking policy living only in my own lists.

Prove it before trusting it

Break it on purpose, in this order:

  1. Query through the address and confirm which node answers. Unique probe hostnames plus each server’s query log beats guessing.
  2. Stop Tailscale on the serving node. Traffic should move to a survivor.
  3. Stop all but one. The last node should serve filtered answers alone.
  4. Kill only the DNS app on the serving node. The watchdog should move traffic by itself, and the node should rejoin when the app restarts.
  5. Resolve a blocked domain through the address on each node. Every answer should match.

What this does not cover: all resolvers down means no DNS. And the pick follows route age rather than latency, so a far node can hold the address while a near one waits. That costs milliseconds on cache misses, which I can live with.