I run an internal ops dashboard on a Windows PC at home. It binds to 127.0.0.1 only and is never exposed to the internet. I wanted to open it from my phone while out.

Tailscale is the obvious answer, and the happy path took ten minutes. Then I spent two days on three wrong turns that are worth writing down, because two of them were security holes and one was a DNS failure that looks like a server failure.

The happy path

tailscale serve --bg --http=80 http://127.0.0.1:3000 

That publishes the local app to the tailnet at http://myhost.<your-tailnet>.ts.net/. Encrypted over WireGuard, reachable only by devices in my tailnet, nothing on the public internet. serve config persists across reboots.

One thing to handle on the app side: most frameworks reject requests whose Host header they don't recognise. I keep an allow-list in env (ALLOWED_HOSTS=myhost.<your-tailnet>.ts.net,myhost) and read it at boot — so changing it means restarting the app, which cost me twenty minutes of confusion once.

If you only need "my devices can reach my app," stop here. The rest of this post is what happens when you add anything else.

Wrong turn 1: one funnel made the whole host public

I wanted an external service to reach exactly one endpoint, so:

tailscale funnel --bg --https=443 --set-path=/api/mcp http://127.0.0.1:3000/api/mcp 

This works. It also does something I did not think about: the host now has public DNS and a real certificate.

The next morning the dashboard was "broken" on my phone. Safari auto-upgraded http://myhost.<your-tailnet>.ts.net/ to https://, the upgrade now succeeded because the host resolves publicly, and port 443 only serves /api/mcp — so the root returned 404. The app was fine the whole time (127.0.0.1:3000 → 200, tailnet :80 → 200).

The dangerous part is what I nearly did next: "just add / to the funnel too."

Do not. Two reasons:

  1. funnel publishes per port. Adding / to 443 puts the entire app on the public internet.
  2. Funnel requests do not carry the Tailscale-User-Login header. If your authorization treats "no user header" as "local request, therefore owner" — mine did — every feature opens with no authentication at all.

That second one is the real lesson. My role check was:

// header present -> that user (guest unless it matches the owner) // header absent -> loopback, i.e. me 

Perfectly reasonable when the only header-less requests come from 127.0.0.1. Completely wrong the moment a public ingress also produces header-less requests. An ingress that strips identity turns a "trusted by default" branch into an open door.

If you expose anything through funnel, scope the authorization to the path you exposed, and assume the request is anonymous.

Wrong turn 2: a port the public DNS doesn't know about

To undo the mess I moved the dashboard to a tailnet-only HTTPS port:

tailscale serve --bg --https=8443 --set-path=/ http://127.0.0.1:3000 

On the PC: fine. On the phone: "Server not found."

It looks like the server is down. It isn't — the name isn't resolving the way I assumed. If the phone's query doesn't go through MagicDNS and lands on public DNS instead (which now exists, thanks to wrong turn 1), it resolves to the funnel ingress — and that ingress only has 443. Port 8443 simply isn't there.

So the failure mode is: name resolves, wrong target, no listener on your port, browser says "server not found," and you go looking at the server.

What actually worked: a raw TCP forward, addressed by IP

tailscale serve --bg --tcp=3001 tcp://127.0.0.1:3000 

Then the phone opens:

http://100.x.y.z:3001/ 

That is the node's tailnet IP. A raw TCP forward doesn't look at Host, so there is no name to resolve — no MagicDNS, no public DNS, no HTTPS auto-upgrade. It is the one entry point that cannot be broken by a DNS detour. I added it to the phone's home screen; the web manifest uses relative paths, so it still installs as a PWA.

Two things to know before you copy this:

  • Add the IP literal to your app's allowed-hosts list — both v4 and the bracketed IPv6 form ([fd7a:...]). Then restart the app.
  • A raw TCP forward also has no Tailscale-User-Login header, so it hits the same "treated as owner" branch as the funnel. The difference is who can reach it: only tailnet peers. That is only safe while your ACLs actually restrict those peers. I have a friend's machine in the tailnet limited to two ports; the day I relax that ACL, this port has to close.

A debugging trick worth keeping

http://100.x.y.z/ -> 404 page not found (from tailscaled itself) 

That 404 is proof the VPN tunnel is alive, independent of your app, your DNS, and your reverse proxy. When the phone says something is unreachable, this one request splits the problem in half: get the 404 and the network is fine, so look at the app or the Host header; get nothing and it is the tunnel or the ACL.

The shape of all three mistakes

Every one came from the same place: I reasoned about the transport and forgot that the transport decides what identity information arrives.

  • serve inside the tailnet → requests carry Tailscale-User-Login
  • funnel from the public internet → no identity, and anyone can reach it
  • raw TCP → no identity, and tailnet peers can reach it

If your authorization has a "no identity means it's me" branch — and a lot of self-hosted setups do, because they started on 127.0.0.1 — then adding any new path into the box silently promotes strangers to you.

The fix is not clever: make "no identity" mean guest, and grant owner explicitly. I'd rather lock myself out once than find out the other way.