Skip to content
tslink

Expose a service

By the end of this guide, a web app in a container answers HTTPS in your tailnet on a name of its own. Then you’ll put several replicas behind one Tailscale Service, so callers use one stable name, and tslink takes a replica out of rotation before it stops.

Before you start

  • You have a tslink network. Getting started creates one on a single host, and Deploy on Swarm one for a stack.
  • HTTPS is enabled for your tailnet, in the DNS settings of the admin console.
  • For a Tailscale Service, the network’s credential gives its nodes tags. Services are hosted by tagged nodes only.

Serve a container on its own name

Give the container a hostname, and a serve rule from a tailnet port to a container port:

docker run -d --network my-tailnet \
  --label tslink.hostname=web \
  --label tslink.serve.443=https:8080 \
  my-web-app

HTTPS on port 443 of web.<tailnet>.ts.net now reaches port 8080 in the container. Tailscale terminates TLS with a certificate for the name, so your app can speak plain HTTP. The rule also takes HTTP, TCP and paths; the tslink.serve.<port> reference has the syntax.

Warning

Tailscale gets a Let’s Encrypt certificate for every name it serves HTTP on, and Let’s Encrypt limits how many new certificates you can get per week. Don’t give each replica a name of its own: put replicas behind a Service, which shares one certificate. HTTPS certificates explains the limits.

Put several containers behind a Service

Create the Service

In the admin console, open Services and create one, for example svc:my-app, with the ports your containers serve. Approve the tag of your network’s nodes for it.

Define only ports that every backend serves. Tailscale’s control server routes traffic to a backend only if it serves every port of the Service, and tslink reports a backend that doesn’t as ports-uncovered.

Make the containers its backends

Label the containers with the Service and what they serve, and give them a readiness healthcheck:

services:
  backend:
    image: nginx:alpine
    networks:
      - tailnet
    labels:
      tslink.service: svc:my-app
      tslink.serve.443: https:80
      tslink.health: "9002"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:9002/ready"]
      interval: 10s
      retries: 3
    deploy:
      update_config:
        order: start-first

tslink.health starts tslink’s readiness endpoint on port 9002 of the container’s loopback interface. It answers 200 only once the backend is advertised and approved, so with order: start-first, Swarm replaces an old task only after the new one actually takes traffic.

Put the labels under labels, not deploy.labels: tslink reads the container’s labels, and deploy.labels only label the Swarm service.

While the plugin restarts, the readiness endpoint doesn’t answer for a few seconds. Keep interval × retries longer than that, so a plugin restart doesn’t mark healthy containers unhealthy. Here, it’s 30 seconds.

Pass the caller’s identity on

If your app needs to know who’s calling, add a serve option. For a TCP service, ?proxy-protocol=2 sends the caller’s tailnet address in a PROXY protocol header. For HTTP, ?accept-app-caps=… passes the caller’s grants for your app capabilities.

Back several Services from one container

A container can back more than one Service. Instead of tslink.service and tslink.serve.<port>, use one label per Service and port:

services:
  backend:
    image: my-ai-app
    networks:
      - tailnet
    labels:
      tslink.service.ai-gateway.serve.443: https:8080
      tslink.service.ai-admin.serve.443: https:8081
      tslink.health: "9002"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:9002/ready"]
      interval: 10s
      retries: 3
    deploy:
      update_config:
        order: start-first

Each Service has to exist in the admin console and approve the node’s tag, as before. tslink advertises each one as soon as it’s configured, and the container is ready once all of them are. See tslink.service.<name>.serve.<port>.

Check that it works

From a device in your tailnet, open the container’s own name:

curl https://web.<tailnet>.ts.net

Ask a backend whether it’s ready:

docker exec <container> wget -q -O- http://127.0.0.1:9002/ready

A ready backend answers:

{"state":"ready","service":"svc:my-app","services":{"svc:my-app":"ready"}}

Then connect to the Service from your device. Its name and address are on its page in the admin console.

Next steps