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.

A service can stay private even when the server has a public IP. What matters is which interface accepts the connection and which firewall path leads to it.

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. Installing Tailscale does not change that.

Start a loopback-only service

Run a small development service on the lab server, bound to loopback, the 127.0.0.1 address that only the machine itself can reach:

python3 -m http.server 3000 --bind 127.0.0.1

This is the strictest choice. Nothing outside the machine reaches it, not even the tailnet interface. We will use Tailscale Serve to publish this loopback listener privately in a later lesson.

For now, let’s test the middle option: bind to the Tailscale interface directly. First find the address, then bind to it:

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/

You get the directory listing back as HTML. It answered, and it traveled the private path.

Verify the denial, not just the success

Success is half the test. Now 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

That timeout is the result you want. Do not assume Tailscale 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. This is the most common way a “private” service turns out to be public: someone installs Tailscale, reaches the app over the tailnet, and never checks the public side.

Add the policy layer too

Interface binding and access policy are separate layers, and you want both. Binding controls where the listener exists. A grant controls who inside the tailnet may use it.

Add a grant for tcp:3000 on tag:server for your user, the same way you did for SSH. Then try the curl from a second user, or remove the grant and watch the request time out. Two layers, two tests.

Lesson completed