Services and SSH
Keep a service private
Bind a small HTTP service to the server and reach it through Tailscale without publishing it on a public DNS record.
8 minute lesson
A service can remain private even when the server has a public IP. The important boundary is which interface and firewall path accepts the connection.
Private is not a property of the service. It is a property of where the service listens and what can reach it. A web app bound to all interfaces on a public server is public, tailnet or not.
Start a loopback-only service
Run a small development service on the lab server, bound to loopback so nothing outside the machine reaches it directly:
python3 -m http.server 3000 --bind 127.0.0.1
Binding to 127.0.0.1 is the strictest choice: even the tailnet interface cannot reach it yet. We will use Tailscale Serve to expose this loopback listener privately in a later lesson.
For now, test the middle option: bind to the Tailscale interface directly.
tailscale ip -4
# 100.101.9.23
python3 -m http.server 3000 --bind 100.101.9.23
The service now listens only on the tailnet address. From your laptop:
curl http://lab-server:3000/
It answers, and it traveled the private path.
Verify the denial, not just the success
Confirm the service is not reachable through the server’s public address:
curl -m 5 http://203.0.113.40:3000/
# curl: (28) Connection timed out
Do not assume Tailscale automatically closes a service that also listens on a public interface. Tailscale adds a private path; it does not remove public ones. A service bound to 0.0.0.0 listens on every interface, public included, and installing Tailscale changes nothing about that. This is the most common way a “private” service turns out to be public.
Restrict its tailnet port with a grant too. Interface binding and access policy are separate layers, and you want both: binding controls where the listener exists, the grant controls who inside the tailnet may use it.
Lesson completed