Fleet guests on Tailscale had a fragile DNS setup. On LXC CTs without systemd-resolved, Tailscale owns `/etc/resolv.conf` and points **all** queries (public included) at the MagicDNS proxy `100.100.100.100`. If tailscaled loses its link, all DNS dies — including the lookup of the coordinator `vpn.echo6.co` needed to reconnect. That circular dependency is a hard brick.
This triggered during the June 2026 patch campaign: a CT went completely offline when tailscaled dropped and couldn't resolve its way back.
MagicDNS itself already works (Headscale `base_domain: echo6.mesh`, upstream 1.1.1.1) — the problem was fragility and lack of public-DNS fallback, not the feature itself.
**Goal of this migration:** bring every Tailscale guest to the robust split-DNS state the two VMs (recon-vm, arr) already had:
- systemd-resolved owns `/etc/resolv.conf` in stub mode
- Tailscale registers as a per-domain resolver for `echo6.mesh`
- A resolved global upstream (1.1.1.1/8.8.8.8) guarantees public DNS even when Tailscale is fully down
- Every guest uniform and documented
---
## Gold-standard target state
Verified on recon-vm (VM 1130). After migration, each guest must satisfy all of these:
Net result: `*.echo6.mesh` resolves via MagicDNS; public DNS is forwarded through Tailscale when it is up, and falls back to resolved's 1.1.1.1/8.8.8.8 when it is down.
---
## Per-guest pre-flight checks
Run these before starting. They determine which steps apply.
### 1. PVE nameserver entry
```bash
# On the PVE host:
grep -i nameserver /etc/pve/lxc/<ID>.conf
```
If a `nameserver:` line is present, clear it **before** anything else:
```bash
sed -i "/^nameserver:/d" /etc/pve/lxc/<ID>.conf
```
> **Note:** `pct set <ID> --delete nameserver` silently no-ops on PVE 9.x. Use the `sed` directly on the conf file.
Commands that run inside the CT are wrapped in `pct exec <ID> -- bash -lc '...'`; commands on the PVE host are noted explicitly.
### Step 0 — Docker DNS pin *(Docker CTs only)*
If `/etc/docker/daemon.json` does not exist:
```bash
pct exec <ID> -- bash -lc "
cat > /etc/docker/daemon.json <<'EOF'
{\"dns\": [\"1.1.1.1\", \"8.8.8.8\"]}
EOF
systemctl restart docker
"
```
If it already exists with other keys, merge the `dns` field manually — do not overwrite blindly.
After `systemctl restart docker`, running containers will briefly restart. Expected and safe.
> **Note on container DNS:** containers on bridge networks will show `127.0.0.11` in their own `/etc/resolv.conf`. That is Docker's embedded relay which forwards to the pinned upstream — expected behavior, not a stub.
---
### Step 1 — Install systemd-resolved *(Bucket I only)*
This guard stops tailscaled racing systemd-resolved's D-Bus at boot. Without it, tailscaled starts before resolved is ready and falls back to "direct" mode — breaking the split-DNS config.
PVE rewrites `/etc/resolv.conf` from the host at `pct start` — and may mark it immutable with `chattr +i`. This service restores the stub symlink before any container services run.
> - Guard #1 (Step 3): tailscaled races resolved's D-Bus on boot → would silently pick "direct" mode. The `ExecStartPre` probe waits for D-Bus to be ready.
> - Guard #2 (Step 4): PVE restores its own static resolv.conf (possibly `chattr +i`) at every `pct start`, before systemd-resolved runs. Without this, the stub symlink is always gone on boot. Both were proven required on a clean guest.
---
### Step 5 — Clear PVE nameserver (host, if still present)
```bash
# On the PVE host:
sed -i "/^nameserver:/d" /etc/pve/lxc/<ID>.conf
```
Safe to re-run if already done in pre-flight.
---
### Step 6 — Live stub swap
Stop tailscaled **first** — while it is running it instantly rewrites `/etc/resolv.conf` the moment the symlink is removed.
**Known pattern:** if `resolvectl status tailscale0` shows no DNS domain scopes after the first restart, restart tailscaled once more. The second restart reliably pushes the `echo6.mesh ~.` scope config. This was observed on all 3 baseline guests.
Expected: `tailscale0` shows `DNS Servers: 100.100.100.100` and `Domains: echo6.mesh ~.`. This confirms accept-dns/CorpDNS is active. (Note: `tailscale status --json | jq .Self.CorpDNS` returns `null` in this Tailscale/Headscale build — use `resolvectl status tailscale0` as the authoritative check. Optionally: `tailscale debug prefs | grep -i corp`.)
All must pass. This is the check the two boot-ordering guards exist to satisfy: stub symlink intact, `mode: stub`, tailscaled in `systemd-resolved` mode, both DNS paths working.
---
## Rollback
Per guest — returns to the known-good static state (public DNS works, no MagicDNS). No reboot required.
**Migration complete 2026-06-22 — all 19 Tailscale guests + 2 VMs on systemd-resolved split-DNS.**
Across all 19 guests the recipe held with no failures. The "two tailscaled restarts" pattern (Step 7) was sometimes but not always needed — Bucket I install-path guests often received the `echo6.mesh ~.` domain scope on the first restart. Both boot guards (resolved-ordering.conf + fix-resolv-stub.service) proved necessary and sufficient for reboot persistence across every guest tested.