I run about 50 small apps on a Mac mini at home: dashboards, PWAs, APIs for my phone apps, a local Ollama. None of them is reachable from the internet, and none listens on my home network either. Each app binds to 127.0.0.1, and tailscale serve puts it on my tailnet at https://<machine>.<tailnet>.ts.net:<port>, with a real certificate, reachable only from my own devices over Tailscale's encrypted WireGuard tunnel.
One command per app, nothing else to run:
tailscale serve --bg --https 5174 http://127.0.0.1:5174
The path a request takes
The internet
Anyone elseno route in
My tailnet
My devices
Open the ts.net URL
Mac mini
Funnel off
tailscale serve
The certificate ends here
127.0.0.1
51 serve entries, every one tailnet only
Right now tailscale serve status lists 51 entries, every one marked tailnet only. None uses Funnel, the feature that would publish a port to the public internet.
Why HTTPS, when the tunnel is already encrypted
WireGuard already encrypts the traffic between my devices, so plain HTTP would be private enough. HTTPS is for the browser. iOS only runs a PWA's service worker on a secure origin, so without it my phone apps lose offline mode and the home-screen install. Tailscale issues the certificate for the machine's ts.net name, so there's nothing to renew by hand.
One detail: use the full name. https://<machine>:5174 fails because the certificate is for the ts.net name, not the short one.
The tunnel, no certificate
HTTP
Private enough: WireGuard encrypts it
iOS won't run the service worker
No offline mode, no home-screen install
The same tunnel, plus a certificate
HTTPS
A secure origin, so the browser trusts it
Service worker, offline mode, install
Tailscale issues the cert: nothing to renew
https://<machine>:5174✗ the cert is for the ts.net name
https://<machine>.<tailnet>.ts.net:5174✓ matches the cert
Use the full nameThe short one fails the certificate.
Why loopback, not the LAN
Binding the app to 127.0.0.1 means the only way in is through tailscale serve. A guest on my Wi-Fi can't reach it, and neither can a misconfigured router. In Docker that's the host part of the port mapping:
ports:
- "127.0.0.1:5174:80" # loopback only; tailscale serve proxies to it
A guest on my Wi-Fi
LAN address ✗
nothing listening
A misconfigured router
LAN address ✗
nothing listening
My phone, on the tailnet
tailscale serve
127.0.0.1 → the app
The Docker port mapping
- 127.0.0.1host address: loopback only
- 5174host port: tailscale serve proxies to it
- 80the port inside the container
One way inThrough tailscale serve, or not at all.
Four traps I hit
| Trap | What I saw | Fix |
|---|---|---|
| Serve entry lost | App answers on 127.0.0.1, the tailnet URL doesn't connect | The entry disappeared after a Tailscale restart. Check tailscale serve status, re-add it |
| App bound to the LAN IP | Serve returns errors, loopback is empty | A change bound Docker to the LAN address. Back to 127.0.0.1:port |
| Bind race (Ollama) | After a reboot, Ollama crash-loops on address already in use | Ollama's "Expose to network" took 0.0.0.0:11434 while serve held the same port on the tailnet address. Turned Expose off |
| Host check (Ollama) | 403 through serve, fine locally | Serve forwards the ts.net Host header and Ollama on loopback rejects it. A small nginx hop rewrites the Host |
The last two only bit Ollama, but the lesson is general: serve proxies the request as-is, so an app that checks the Host header or wants the port for itself needs a look.
1Serve entry lost
Fixre-add it; the app was fine all along
2App bound to the LAN IP
Fixbind back to 127.0.0.1
3Bind raceOllama
Fixturn off Expose to network
4Host checkOllama
Fixan nginx hop rewrites the Host
The lessonServe passes the request on as-is.
What I'd tell someone starting
- Bind every app to loopback and let
tailscale servebe the only door. - Use HTTPS from day one if a phone will open it. PWAs need it.
- Keep a list of the ports. With 50 apps, a port registry saves you from two apps fighting over one number.
- Check
tailscale serve statusfirst when a URL stops working. Most of my outages were a missing entry, not a broken app.
-
1Loopback only
Serve is the one door
-
2HTTPS, day one
If a phone opens it, its PWA needs it
-
3A list of the ports
Two apps never fight over one number
-
4Status first
Most outages: a missing entry, not a broken app
Plain text: tailscale-serve-self-hosted-apps.md · Atom feed · All notes