How Tailscale Services actually work (and why my dashboard wouldn't connect)

#tailscale #networking #devops

I spent an evening wrestling with Tailscale’s Services feature trying to expose a local dashboard over my tailnet. The admin console kept showing:

Advertising the service, but some required ports are missing

I assumed this was a port number mismatch. It wasn’t. Or at least, not only that.

The setup

I have a Python app (Hermes) running on my Mac, listening on port 9119. I wanted to access it from other devices on my Tailscale tailnet using a stable hostname like hermes-dashboard.<tailnet>.ts.net instead of memorizing IPs.

The first real lesson: 127.0.0.1 is not enough

My first instinct was to check what was actually listening on 9119:

sudo lsof -iTCP:9119 -sTCP:LISTEN -P -n

Output:

python3.1  22481  tara  ...  TCP 127.0.0.1:9119 (LISTEN)

Tailscale cannot route traffic to a port bound only to 127.0.0.1. The loopback interface is, by definition, only reachable by the local machine. For Tailscale to hand off incoming connections to your app, the app needs to be listening somewhere Tailscale can reach — typically 0.0.0.0 (all interfaces) or the specific Tailscale IP.

So my first fix:

hermes dashboard --host 0.0.0.0 --insecure

Now lsof showed *:9119 (LISTEN). The warning should clear, right?

It didn’t.

The second lesson: Services ≠ just a port on a node

There are two different ways to expose something in Tailscale:

  1. Just run it on a node. Any tailnet device can reach http://tarangan:9119 because they share the tailnet. No Services feature needed.

  2. Create a Service. This gives your thing a virtual IP (VIP) — a dedicated Tailscale IP that routes to one or more backing hosts. The VIP has its own MagicDNS name.

I wanted option 2. But option 2 has more machinery. The admin console can define a service, but the host node itself has to actively advertise that it’s serving the service. Merely being listed as a host isn’t enough.

I could see the Service was defined correctly:

tailscale status --json | jq '.Self.CapMap."service-host"'

But:

tailscale status --json | jq '.Self.AdvertisedServices'
# null

tailscale serve status
# No serve config

My node wasn’t actually advertising anything.

Dead end: tailscale set --advertise-services

Naively tried:

sudo tailscale set --advertise-services=svc:hermes-dashboard

Flag doesn’t exist in the macOS client’s tailscale set command. Failed with flag provided but not defined.

What actually worked: tailscale serve --service

The correct command on macOS:

tailscale serve --service=svc:hermes-dashboard --https=9119 9119

Breaking that down:

  • --service=svc:hermes-dashboard — which Service to advertise
  • --https=9119 — the public port exposed on the VIP, using HTTPS
  • 9119 — the local port to proxy to (my Hermes app)

Critical detail: the public port has to match what the service definition in the admin console says. My service was defined as tcp:9119, so I had to serve on 9119 publicly.

A quirk worth knowing

After it was working, I still got:

$ tailscale serve status
No serve config

$ tailscale status --json | jq '.Self.AdvertisedServices'
null

This threw me for a loop. But the actual endpoint worked. Best I can tell, this is a reporting quirk in the macOS Tailscale client. The admin console is the real source of truth. If it shows the service as healthy, it is.

The final security tweak: rebind back to localhost

Once Tailscale Serve is handling the proxy, the app doesn’t need to be on 0.0.0.0 anymore:

hermes dashboard --insecure  # defaults to 127.0.0.1

Now the access path is:

  1. Client on tailnet hits https://hermes-dashboard.<tailnet>.ts.net:9119/
  2. Tailscale routes to the VIP
  3. VIP lands on my Mac’s tailscaled
  4. tailscaled terminates TLS and proxies to http://127.0.0.1:9119
  5. Hermes receives the request

The app is fully isolated from everything except the tailnet.

Mental model I wish I’d had at the start

A Tailscale Service is three separate things, and all three need to line up:

  1. The service definition (admin console): allocates a VIP, declares a name, declares what port(s) the service speaks on.
  2. The host advertisement (tailscale serve --service=... on the host): tells Tailscale “this node is actively serving that VIP, and here’s how to proxy traffic to the app.”
  3. The app itself: has to be reachable from the proxy (which means either on all interfaces, or on 127.0.0.1 if the proxy is on the same machine).

Debugging tip: figure out which of those three layers is broken. The warnings don’t always point at the right one.

Starting clean: the 4-step version

Step 1: Create the service in the admin console. Name it, declare the endpoint as tcp:PORT.

Step 2: On the host machine, run:

tailscale serve --service=svc:NAME --https=PUBLIC_PORT LOCAL_PORT

Step 3: Go to the admin console, find the service in the Services page, click Approve.

Step 4: From any other tailnet device, open the MagicDNS URL. It works.

Checklist for next time

  1. Start the app, bound to 127.0.0.1 (or 0.0.0.0 if the proxy is on a different machine)
  2. Verify with lsof -iTCP:PORT -sTCP:LISTEN -P -n
  3. Create the Service in the admin console, declaring the public port
  4. On the host, run tailscale serve --service=svc:NAME --https=PUBLIC_PORT LOCAL_PORT
  5. Approve the host in the admin console if auto-approval isn’t set up
  6. Test from another tailnet device with the MagicDNS URL
  7. Tighten the app’s bind to 127.0.0.1 if it was on 0.0.0.0 earlier