Brahim Bousnguar

Notes · Article

Tailscale Serve: HTTPS for 50 self-hosted apps, none of them on the internet

By · · 2 min read

Listen · 4:23 · read by an AI voice

Listen to this article0:00 / 4:23

Read by an AI voice · MP3

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

Zones inside zones. The request is encrypted all the way to serve, and only the last step inside the box is plain HTTP. Each square is one of the 51 serve entries.

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.

Both phones sit on the same encrypted tunnel, the blue line. What changes is what the browser lets my phone apps do, and that depends on the certificate, not on the network.

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
  1. A guest on my Wi-Fi

    LAN address ✗

    nothing listening

  2. A misconfigured router

    LAN address ✗

    nothing listening

  3. 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.

Three ways to knock, one door that opens. The highlighted part of the mapping is what keeps the other two out.

Four traps I hit

TrapWhat I sawFix
Serve entry lostApp answers on 127.0.0.1, the tailnet URL doesn't connectThe entry disappeared after a Tailscale restart. Check tailscale serve status, re-add it
App bound to the LAN IPServe returns errors, loopback is emptyA 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 useOllama'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 locallyServe 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.

  1. 1Serve entry lost

    Fixre-add it; the app was fine all along

  2. 2App bound to the LAN IP

    Fixbind back to 127.0.0.1

  3. 3Bind raceOllama

    Fixturn off Expose to network

  4. 4Host checkOllama

    Fixan nginx hop rewrites the Host

The lessonServe passes the request on as-is.

The same path four times, with the hop where each trap stopped my request. The yellow square is the request; the last two traps only hit Ollama.

What I'd tell someone starting

  • Bind every app to loopback and let tailscale serve be 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 status first when a URL stops working. Most of my outages were a missing entry, not a broken app.
  1. 1Loopback only

    Serve is the one door

  2. 2HTTPS, day one

    If a phone opens it, its PWA needs it

  3. 3A list of the ports

    Two apps never fight over one number

  4. 4Status first

    Most outages: a missing entry, not a broken app

The same four habits, drawn. The fourth has the blue frame because that's where most of my outages turned out to be.

Plain text: tailscale-serve-self-hosted-apps.md · Atom feed · All notes

Contact

Say hello.

b.bousnguar@gmail.com

SAP Commerce Cloud, MuleSoft and Salesforce integration · Nantes · EN / FR