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:
-
funnelpublishes per port. Adding/to 443 puts the entire app on the public internet. - Funnel requests do not carry the
Tailscale-User-Loginheader. 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-Loginheader, 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.
-
serveinside the tailnet → requests carryTailscale-User-Login -
funnelfrom 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.


