Daniel's traveling in the US right now, laptop connected to his tailnet, and he's built something that's basically an SSH MCP server — a centralized, authenticated host inventory that his agent reads to figure out where to run inference. The agent sees a tailnet address, SSHes over it, and what's actually happening is a direct WireGuard tunnel between his laptop and whatever remote VPS box he's hitting. The setup works. The question is how to configure it properly so it keeps working when he starts migrating the rest of his stack.
He's got three things he wants us to dig into. First, is the simple answer — just use tailnet names everywhere — actually sufficient? Second, what's the real scope of work when you go to migrate Dockerfiles and compose files that have hard-coded public IPs and remote paths? And third, how does MagicDNS actually resolve those friendly hostnames, and where can it bite you? So today we're walking through all three, starting with the one that sounds too easy to be true.
The simple answer is yes, it works — because Tailscale assigns every node a stable address in the CGNAT range, and MagicDNS maps hostnames to those addresses. So if your remote VPS is named ollama-box, your agent just needs to know ollama-box dot your-tailnet dot ts dot net, and the WireGuard tunnel handles the rest. No port forwarding, no public IP exposure, no firewall rules. The SSH MCP server part — the host inventory — is genuinely the cleanest piece. It's just a list of names and aliases. The agent reads the list, picks a host, and SSHs over the tailnet. That part really is as simple as it sounds.
And yet.
And yet. The moment you have a Docker container that needs to reach a service on another node, you hit the container networking boundary. Containers don't inherit the host's Tailscale interface by default. They're in their own network namespace. So you can't just swap a public IP for a tailnet name in an environment variable and call it done — the container can't resolve that name, and even if it could, it can't reach the CGNAT address because it doesn't have a route to the tailnet interface.
So the simple answer assumes every service in the stack is Tailscale-aware, and Docker containers, by default, are not.
The standard pattern is the sidecar approach. You run Tailscale in its own container inside the same compose file, and other containers share its network namespace using network mode service colon tailscale. That way, any container that needs tailnet access routes through the Tailscale sidecar. It works — but it means every compose file that needs cross-node communication has to be restructured. You're not doing a find-and-replace on a config string. You're adding a new service definition, changing network modes, and potentially rethinking how containers discover each other.
Give me a concrete example. Someone's got a compose file with a service that needs to hit an Ollama instance on a remote VPS.
The naive approach is you set OLLAMA HOST to http colon slash slash a hundred dot x dot y dot z colon eleven thousand four hundred thirty four. You figure the CGNAT address is routable, so this should work. But if the container doesn't have tailnet access, that IP is unreachable — the packet never leaves the container's network namespace.
Because the CGNAT range is non-routable on the public internet. That's the whole point. It's only meaningful inside the WireGuard tunnel.
So the fix is you add a Tailscale service to your compose file, using the tailscale image with the right auth key and state volume. Then on the service that needs to reach Ollama, you set network mode to service colon tailscale. Now that container shares the Tailscale container's network stack, which means it can resolve tailnet hostnames and route to CGNAT addresses. The environment variable becomes OLLAMA HOST equals http colon slash slash ollama-box dot your-tailnet dot ts dot net colon eleven thousand four hundred thirty four, and it actually works.
And that's the part where the migration stops being a string replacement and starts being an architectural change.
It is. And it gets messier when you have services that hard-code remote filesystem paths. If a compose file mounts slash mnt slash remote-vps slash data, and that path was only valid because the host had the remote filesystem mounted over a public IP, switching to a tailnet address doesn't fix the mount. You need to re-export the filesystem over the tailnet interface — set up an NFS export that listens on the tailnet IP, and then mount that from the container. The DNS resolution isn't the problem there. Reachability is.
So the simple answer is simple for the SSH MCP server itself — the host inventory — and it gets progressively less simple for everything that sits behind it.
That's the gap Daniel's pointing at. The agent can SSH into the remote box just fine. But once it's there, if the services running on that box reference each other by public IP or localhost, those references break when you try to reach them over the tailnet. The agent can get in the front door, but the rooms inside don't connect to each other.
Let's talk about the migration problem in practice. A typical self-hosted AI gateway — what are we looking at, five to ten services?
At least. You've got a reverse proxy — Caddy or Nginx. An inference server — Ollama, vLLM, TGI. A vector database — Qdrant or Weaviate. Maybe Prometheus and Grafana. A RAG pipeline. Each of those services might reference remote hosts via environment variables, config files, or hard-coded URLs. The migration isn't one change — it's a combinatorial audit. You have to find every place a service reaches out to another host and make sure that reference works over the tailnet.
And the failure modes are the kind that are incredibly hard to debug because they're partial. Some things work, some don't, and you don't know why.
Let me walk through the three I think about most. Failure pattern number one: split DNS. If some services resolve tailnet names and others don't — because they're in a different network namespace, or because the container's etc resolv dot conf doesn't point to Tailscale's DNS — you get partial connectivity. You SSH into the VPS, curl the tailnet address, it responds. You run the same curl from inside a container, it fails. And you spend an hour checking firewalls before you realize the container literally cannot resolve the name.
The host's resolv conf and the container's resolv conf are two different files.
And Tailscale's DNS resolver is layered on top of whatever the host's DNS is. It intercepts queries for the tailnet search domain and forwards everything else upstream. But if a container has its own DNS configuration — which Docker sets by default, usually pointing at Google's eight eight eight eight or Cloudflare's one one one one — it bypasses Tailscale's resolver entirely. The tailnet name means nothing to Google's DNS.
So the fix is making sure the container uses Tailscale's DNS, which — if you're using the sidecar pattern — happens automatically because the container inherits the Tailscale sidecar's network stack, including its DNS configuration.
But if you're not using the sidecar — if you've tried some other approach, like binding the host's Tailscale interface directly into the container — you have to handle DNS manually, and that's where people get lost.
Failure pattern number two.
Tailnet name collisions. MagicDNS uses the tailnet name as a search domain. So my-server resolves to my-server dot your-tailnet dot ts dot net. But if you have a hostname that conflicts with a public domain — say you've got a service called api, and your container also needs to reach api dot some-saas-provider dot com — the search domain order matters. Tailscale's DNS can override public DNS for specific domains, but if you haven't configured it correctly, the resolver might try to resolve api as a tailnet name first, fail, and then... it depends on the resolver behavior. Some resolvers will then try the full public domain. Some won't.
And the failure is silent. The name just doesn't resolve, and you get a timeout or a connection refused, and the actual cause is buried in DNS resolution order.
Which is the kind of bug that takes a Tuesday afternoon and turns it into a character-building exercise.
Failure pattern number three.
The localhost problem. If the remote VPS has services bound to localhost or one twenty seven dot zero dot zero dot one — which is the default for a lot of services, because it's the secure default — they won't be reachable over the tailnet. The service needs to listen on zero dot zero dot zero dot zero or specifically on the tailnet interface IP. The service works fine when you SSH in and curl localhost, but fails when the agent tries to reach it over the tailnet address. The packets arrive at the VPS, but nothing is listening on that interface.
And the developer's instinct is to check the tunnel, check the DNS, check the firewall — because the service is clearly running, you just tested it locally.
And it's not any of those things. It's the bind address. One line in a config file. But finding it when you've eliminated everything else — that's the migration tax. That's the cost of moving from public IPs to tailnet addresses that isn't obvious until you're in the middle of it.
There's a knock-on effect here worth sitting with. Once you migrate to tailnet addresses, you've created a dependency on Tailscale's control plane. If the coordination server is down, existing WireGuard connections continue — the tunnels themselves are peer-to-peer — but new nodes can't join and DNS resolution for new tailnet names fails.
Tailscale had a notable outage in September twenty twenty-four. The control plane was down for about four hours. Existing tunnels stayed up — if you were already connected to a node, the WireGuard connection kept working. But if a node changed its IP during that window, or if you needed to resolve a tailnet name you hadn't cached, you were stuck. The DNS updates couldn't propagate because the coordination server wasn't distributing them.
Four hours is not nothing. If you're running a production gateway and your agent can't discover new nodes for four hours, that's an availability profile different from public IPs with traditional DNS. Traditional DNS has its own failure pattern, but they're ones the ops community has decades of experience mitigating.
The tradeoff is real. Public IPs with DNS give you independence from any single control plane but expose you to DDoS, IP leakage, and the whole port-forwarding nightmare. Tailnet addresses give you a private, encrypted overlay with no public exposure, but you're depending on Tailscale's infrastructure for coordination. Neither is strictly better. They're different failure domains.
And Daniel's architecture — the SSH MCP server pattern — is essentially a control plane itself. The question is what happens when the control plane it depends on has a hiccup.
That's the thread I want to pull when we get into MagicDNS. But first I want to make sure we've covered the Docker migration scope properly, because this is where most people will actually get stuck.
Walk me through it. I've got a compose file with a reverse proxy, an inference server, a vector database. What am I actually changing?
Step one, you add a Tailscale service to the compose file. That's the sidecar. It needs an auth key — you generate that in the Tailscale admin console — and a volume to persist its state so it doesn't re-register as a new node every time the container restarts. Step two, for every service that needs to reach another node on the tailnet, you set network mode to service colon tailscale. Step three, you go through every environment variable, every config file, every hard-coded URL, and replace public IPs with tailnet hostnames.
That third step is the one that doesn't scale.
It scales, but it's tedious. And the risk is you miss one. You change ninety-five percent of the references, but there's one config file buried in a volume mount that still points to a public IP that no longer exists, and that service silently fails. The debugging surface is every string in every config file across every service.
And if you've got a RAG pipeline that references a vector database at http colon slash slash ten dot zero dot zero dot five colon eight thousand, changing that to the tailnet hostname only works if the container can resolve that name and the vector database is listening on the tailnet interface.
Both of which we've established are not defaults. The migration isn't just changing strings — it's changing assumptions about how services discover and reach each other.
So the simple answer — point everything at tailnet names — is true in the same way that "to lose weight, eat less and exercise more" is true. The statement is correct. The execution is where it gets interesting.
And the execution is where we've been living for the last ten minutes. Let's go deeper on MagicDNS, because that's the layer underneath all of this, and I think it's the part Daniel said he's never properly understood.
Where do you even start with MagicDNS?
The thing to understand is that MagicDNS is not a separate DNS server. It's a search-domain-based overlay. When you enable MagicDNS on your tailnet, Tailscale configures each node's local DNS resolver to intercept queries for a specific search domain — your dash tailnet dot ts dot net. So if you've got a node named gpu-box, and you type gpu-box, the local resolver appends the search domain and queries Tailscale's internal DNS, which returns the CGNAT address.
And everything else — queries for public domains — gets forwarded to whatever upstream DNS the host was already using.
Right. Tailscale's DNS resolver is layered on top of the host's existing DNS. It's not replacing it. It's intercepting a specific namespace. That's elegant, but it also means you've got two failure domains stacked on each other. If the host's upstream DNS is misconfigured or slow, public domain resolution suffers. If Tailscale's coordination server is unreachable, tailnet name resolution suffers. And if there's a conflict between the two — like a tailnet hostname that matches a public domain — the resolution order determines which one wins.
The resolution order is configurable?
It is, but the default behavior is search domain first. So if you've got a node called api, and your application tries to resolve api, the resolver first tries api dot your-tailnet dot ts dot net. If that fails, it depends on the resolver's configuration whether it then tries api as a bare hostname or gives up. This is where the silent failures come from.
Naming a node api is, in practice, squatting on the name api for your entire tailnet.
For any device on your tailnet, yes. And if you've got a service that needs to reach an external API at api dot stripe dot com, the resolver will try to resolve api as a tailnet name first. It'll fail, eventually, but the delay can cause timeouts upstream.
That's the kind of thing that's obvious once you say it, but nobody thinks about it when they're naming their first few tailnet nodes.
Nobody thinks about DNS until DNS breaks. And MagicDNS adds a layer of indirection that makes the breakage harder to reason about because you're not just debugging DNS — you're debugging DNS plus an overlay network's name resolution, and the two interact in ways that aren't documented in any one place.
Let's talk about what happens when a tailnet node changes its IP. The CGNAT address is supposed to be stable, but nodes can get reassigned.
They can, and when they do, Tailscale's coordination server pushes an update to the DNS records. But that update isn't instantaneous, and if a node is offline when the update propagates, it'll have a stale record in its cache. The next time it tries to resolve that hostname, it gets the old CGNAT address, tries to connect, and fails.
How long does the stale record live?
That depends on the TTL that Tailscale sets on its DNS responses. Tailscale uses a relatively short TTL for exactly this reason, but short isn't zero. If the TTL is sixty seconds, and your node gets a new IP, there's a sixty-second window where any node that cached the old record is reaching for a dead address.
In Daniel's architecture, the SSH MCP server's inventory is static. It's a list of hostnames. If MagicDNS returns a stale address, the agent has no mechanism to detect that it's talking to the wrong box. It just tries to connect, and either it fails — which is the good outcome, because you notice — or worse, it connects to something that happens to be at the old address and starts sending inference requests to the wrong machine.
That's the nightmare scenario. The agent thinks it's talking to gpu-box, but gpu-box changed its IP, and the old address now belongs to a different node — or to nothing, but something else on the tailnet is responding on that port for unrelated reasons. The agent has no way to verify the identity of the box it's connected to beyond the SSH host key, and if the host key changed too...
Then you're in a world of pain.
This is where the architecture has a genuine gap. The SSH MCP server pattern is elegant — centralized inventory, agent reads it, connects over WireGuard — but it assumes the inventory is accurate and the DNS resolution is correct. There's no health check, no retry logic with backoff, no mechanism for the agent to say "I expected to reach gpu-box but the host key doesn't match, something is wrong."
That's not a Tailscale problem, exactly. It's an architectural problem that Tailscale's abstractions make easy to overlook.
That's the thing about abstractions. They hide complexity until the complexity bites you. MagicDNS is a good abstraction — it makes tailnet names work like DNS names, and for ninety-nine percent of use cases, that's all you need. But when you're building a self-hosted AI gateway that follows you geographically and routes inference requests based on a static inventory, you're in the one percent. You need to know what's happening under the hood.
I want to circle back to something you said earlier about the control plane dependency. The September twenty twenty-four outage — four hours where new nodes couldn't join and DNS updates couldn't propagate. If Daniel's agent tries to resolve a tailnet name during that window and the record isn't cached, what happens?
The resolution fails. The agent can't reach the node. If the agent has fallback logic — try another node, or queue the request — it degrades gracefully. If it doesn't, it hangs or crashes. And most agents being built right now don't have that fallback logic, because the people building them are focused on getting the inference pipeline working, not on handling DNS infrastructure outages.
The availability profile of a tailnet-based architecture is different from a public-IP-based one, and most people don't model it. They assume Tailscale is always-on, always-reliable, and the control plane is someone else's problem.
It is someone else's problem — until it's your problem. And the person whose agent can't reach the inference server at two in the morning doesn't care whose problem it's supposed to be.
Hilbert: Nineteen ninety-eight. I was running a small ISP out of a basement in Hartford. We had four hundred dial-up customers and our own DNS infrastructure — BIND on a Sun Ultra 5. One Tuesday I pushed a zone file with a trailing dot in the wrong place, and for six hours, every single customer who tried to resolve anything in our search domain got a referral to a nameserver that didn't exist. Email stopped. Web browsing — what there was of it in ninety-eight — stopped. Four hundred people calling the support line, which was just my desk phone.
Hilbert: I've been suspicious of anything that calls itself magic DNS ever since.
Hilbert: What I'm hearing is a setup where a single DNS resolution failure — a timeout, a wrong search domain, a stale cache — means the agent can't find the inference server. And because Tailscale's DNS is layered on top of whatever the host's DNS is, you've got two failure domains stacked on each other. The host's upstream resolver could be slow. The tailnet coordination server could be unreachable. The search domain could shadow a public name you actually need. Any one of those breaks the chain.
Hilbert: What I want to know is what happens when MagicDNS returns a stale record because the tailnet node changed its IP. Does the agent retry? Does it fall back to a different node? Or does it just sit there waiting for a response from a box that isn't the box it thinks it is?
Hilbert: Because if it just sits there — and I've got a feeling it just sits there — you've built a system where the failure pattern is silent and the blast radius is every inference request that arrives during the stale window.
The retry question is the one that's been nagging at me. The inventory is static. The agent trusts it. There's no health check loop that says "before I send this request, let me verify the host key matches what I expect."
Adding that health check isn't trivial, because what are you checking against? If you store the expected host key in the inventory, you've created a key distribution problem. If you don't, you're trusting DNS to return the right address and SSH to verify the host key on first connect, which is fine until the address changes and you get a key mismatch warning that most agent implementations will either ignore or treat as a fatal error with no graceful fallback.
Hilbert: The Sun Ultra 5 had a SCSI disk that made a sound like a coffee grinder when it was doing a zone transfer. You could hear when DNS was working. That's the kind of observability I miss.
Hilbert: With this setup, you can't hear anything. The agent either connects or it doesn't. If it doesn't, you get an error somewhere in a log file you're not watching. If it connects to the wrong thing — well. You probably don't find out until the inference results look wrong, and by then you've been sending requests to the wrong box for an hour.
Hilbert: I'm not saying don't use it. I'm saying if I were building this, I'd want to know exactly what the agent does when DNS lies to it. And DNS always lies eventually.
The agent's behavior on resolution failure is the gap. Daniel's SSH MCP server knows what hosts are available, but it doesn't know if they're healthy, and it doesn't know if the address it resolved is the right one. That's not a Tailscale problem — it's a control plane design problem that the tailnet abstraction makes easy to skip.
The fix isn't necessarily complicated. A health check that verifies the host key before routing requests. A retry loop with exponential backoff. A fallback to a secondary node if the primary doesn't respond. But none of that is in the current architecture, because the current architecture assumes the simple answer — point everything at tailnet names — is sufficient.
It is sufficient, until it isn't. And the "until it isn't" moment is what Hilbert's describing — a stale DNS record, a control plane outage, a search domain collision. The architecture works beautifully in the happy path, and the happy path is admittedly most of the time. But when you're routing inference requests through it, the unhappy path matters more than usual, because the cost of a failure isn't a dropped SSH connection — it's an agent that can't do its job.
If you take one thing from this, it's that the migration from public IPs to tailnet addresses isn't a string replacement — it's a change in how your services discover and trust each other. The simple answer works for the SSH MCP server itself, but everything behind it needs to be rethought.
The thing that changed for me is the retry gap. I'd been thinking about this as a configuration problem — get the DNS right, get the sidecar pattern right, and you're done. But Hilbert's right. The architecture assumes DNS never lies, and that's not an assumption you get to make. The next evolution of this pattern needs a health check.
The open question I'm left with is whether the Tailscale-centric architecture becomes the default for self-hosted AI gateways, or whether we'll see purpose-built tools that abstract away the WireGuard and DNS complexity entirely. The SSH MCP server pattern — a centralized, authenticated host inventory that an agent reads to decide where to run inference — is essentially a control plane. The next step might be a control plane that doesn't just list hosts but actively manages routing, failover, and DNS health checking.
If you're running your own AI gateway and you've hit one of these failure pattern — the split DNS, the localhost bind address, the stale record — or if you've found a clean way to handle the Docker sidecar pattern, we want to hear about it. Send your setup to show at my weird prompts dot com.
Thanks to our producer Hilbert Flumingtop, who has apparently been waiting twenty-seven years to tell that Sun Ultra 5 story.
This has been My Weird Prompts. We'll be back soon.