Post

TSDProxy without exposing containers to the host

Harianto van Insulinde

TSDProxy without exposing containers to the host

I already had three Unsloth Studios on one AMD GPU. The next itch was reaching them from a laptop without publishing 8888 on the machine, and without a Tailscale sidecar in every compose file.

TSDProxy is the one-label version of that: it watches Docker, spins a Tailscale node per labelled container, and reverse-proxies HTTPS on the tailnet to whatever is listening inside the container. The getting-started compose still maps 8080:8080 and the sample nginx still maps 8111:80. That is the default people copy. It is also the bit I did not want.

Host ports are a LAN surface. On a box that already runs Cloudflare Tunnel for some things and Tailscale for the rest, punching 8888 (or a “random high port I never use”) into the host is extra attack area for no extra access.

Two ways TSDProxy can reach a container

TSDProxy has to build a target URL. Almost every “it shows in the dashboard but 502s” is that URL pointing somewhere the TSDProxy container cannot dial.

Through the host. Default getting-started: extra_hosts: host.docker.internal:host-gateway, targetHostname: host.docker.internal. TSDProxy leaves its own network, hits the Docker gateway, and needs a published port on the other side. No ports: in compose, nothing to hit. This is why GitHub threads keep asking “do I need to map the ports?” and why “map a high port I never use” became folklore. The mapping is not for you. It is a hole for TSDProxy via the host.

On a shared Docker network. TSDProxy and the app sit on the same bridge. Target is http://172.20.0.3:8888 — the container IP and the port the process actually binds. The host never sees it. Official docs call this scenario 1. The default TSDProxy config does not prefer that path: tryDockerInternalNetwork is false, so the proxy still aims at host.docker.internal unless you help it.

I wanted the second path. The first is how you accidentally expose Unsloth Studio to whatever can reach the box.

Name the network once

TSDProxy lives in its own compose project. The trick is not a sidecar. It is naming the default network so other projects can join it as external.

yml
networks:
  default:
    name: tsdproxy
    driver: bridge

volumes:
  data:

services:
  tsdproxy:
    image: almeidapaulopt/tsdproxy:2
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - data:/data
      - ./config:/config
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "dash"
      tsdproxy.dash.visible: "false"
      tsdproxy.autodetect: "true"

No ports:. The dashboard is itself a Tailscale proxy named dash, and it stays off the dashboard list. Auth key lives in env or ./config/tsdproxy.yaml — not in the compose I paste into a post.

yaml and compose are a pair

The shared network is not enough on its own. TSDProxy still has to pick a target URL. That choice lives in two files that have to match: ./config/tsdproxy.yaml (the provider) and the service labels in docker-compose.override.yml. Mix them and you get the host-gateway path with no published port.

Both paths below still join tsdproxy and still omit ports:. The only moving part is the provider flag versus the auto-detect label.

tsdproxy.yamlmatching Compose labelsWhat TSDProxy dials
tryDockerInternalNetwork: false (default)add tsdproxy.autodetect: "true"probes until the container IP on the bridge answers
tryDockerInternalNetwork: trueomit tsdproxy.autodetectprefers the container IP; port.1 is enough

Default provider (false)

Leave TSDProxy on defaults. Then Unsloth only came up when each service set auto-detect. Probing is how it still finds the container IP even though the provider would rather use host.docker.internal.

yml
# ./config/tsdproxy.yaml  —  default
docker:
  local:
    host: unix:///var/run/docker.sock
    targetHostname: host.docker.internal
    tryDockerInternalNetwork: false
    defaultProxyProvider: default
yml
# docker-compose.override.yml  —  pairs with false
services:
  unsloth-amd:
    networks:
      - tsdproxy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "unsloth-amd"
      tsdproxy.autodetect: "true"
      tsdproxy.port.1: "443/https:8888/http"

Without tsdproxy.autodetect: "true" here, the default provider never leaves the host gateway.

Prefer the Docker-internal IP (true)

Flip the provider. Then drop the auto-detect label. port.1 already names the process port; the yaml is what prefers the container IP over host.docker.internal.

yml
# ./config/tsdproxy.yaml  —  prefer container IP
docker:
  local:
    host: unix:///var/run/docker.sock
    targetHostname: host.docker.internal
    tryDockerInternalNetwork: true
    defaultProxyProvider: default
yml
# docker-compose.override.yml  —  pairs with true
services:
  unsloth-amd:
    networks:
      - tsdproxy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "unsloth-amd"
      tsdproxy.port.1: "443/https:8888/http"

host.docker.internal can stay in yaml as a fallback for the odd host-mode service. It is no longer the path for Unsloth. This is the pair I run.

Labels belong in the override

The Unsloth compose already had a job: GPU devices, shared install volume, per-service data. I did not want Tailscale labels living in that file. Same pattern as Cloudflare Tunnel on this blog: keep docker-compose.yml about the app, put the networks in docker-compose.override.yml.

Main file: comment the host ports out. Studio still binds 0.0.0.0:8888 inside the container. That is enough. ports: is how you publish to the host. EXPOSE in a Dockerfile is documentation. Neither is required for a container on the same Docker network as TSDProxy.

Full override for the three Studios — the true pair from above, plus the Cloudflare network for the public door:

yml
networks:
  cloudflared:
    external: true
  tsdproxy:
    external: true

services:
  unsloth-amd:
    networks:
      - cloudflared
      - tsdproxy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "unsloth-amd"
      tsdproxy.port.1: "443/https:8888/http"
  unsloth-planner:
    networks:
      - cloudflared
      - tsdproxy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "unsloth-planner"
      tsdproxy.port.1: "443/https:8888/http"
  unsloth-builder:
    networks:
      - cloudflared
      - tsdproxy
    labels:
      tsdproxy.enable: "true"
      tsdproxy.name: "unsloth-builder"
      tsdproxy.port.1: "443/https:8888/http"

tsdproxy.port.1 is the Traefik-shaped contract: left side is what the tailnet sees (443/https), right side is the process inside the container (8888/http). Tell the proxy the container port. Do not publish it on the host.

If you are on the default pair instead, add tsdproxy.autodetect: "true" to each of those labels: blocks. Do not mix: tryDockerInternalNetwork: false without auto-detect is the 502.

v1 had tsdproxy.container_port. It still works as a leftover. I would not add it to a new service; port.1 already names 8888.

Two networks on the same service is not a contradiction. tsdproxy is the private door (Tailscale, my devices). cloudflared is the public door for whatever I actually want on the internet. Neither door is 0.0.0.0:8888 on the host.

What went wrong when I skipped the network

If TSDProxy and the app do not share a network, auto-detect tries the gateway and published ports. No published ports: no port found in container, or a 502 to host.docker.internal:something. The container can look fine in docker ps. The proxy still has nowhere to dial.

Trace logs on TSDProxy show the candidate URLs. That is faster than adding a dummy host port “just to see”. The fix is the pair above: same network, then either tryDockerInternalNetwork: true or tsdproxy.autodetect: "true" on the service — not a host port. Default yaml plus no auto-detect is how I sent it back to host.docker.internal with nothing listening.

I also do not bind the TSDProxy dashboard to the host. Default examples map 8080:8080. With adminAllowLocalhost that is a wide-open admin UI on the Docker bridge. Proxy it as dash on the tailnet and leave host 8080 closed.

What I would not add

I am not putting a Tailscale sidecar next to each Studio. That was the whole point of TSDProxy.

I am not publishing 127.0.0.1:8888:8888 as a compromise. Loopback publish still occupies a host port and still trains the habit that apps must be mapped.

I am not putting Funnel on these. Unsloth Studio is a passworded UI on a GPU box. Tailscale is the ACL. Funnel is for things I want on the public internet; Cloudflare Tunnel already covers that path.

Closing

The useful bit was not “Tailscale for Docker”. It was noticing that the getting-started port maps are a routing convenience for the host-gateway path, not a requirement of the proxy. Name a bridge tsdproxy. Join it from the override. Label 443/https to the container port. Prefer the Docker-internal IP in tsdproxy.yaml — or, if you leave that default, put tsdproxy.autodetect: "true" on the service. Comment the ports: out.

The GPU box still does not listen for Unsloth on the LAN. The laptop still opens https://unsloth-amd.<tailnet>.ts.net. That is the shape I wanted.


Stack: TSDProxy 2 (almeidapaulopt/tsdproxy:2), named Docker network tsdproxy, tryDockerInternalNetwork: true (so Unsloth labels skip tsdproxy.autodetect), tsdproxy.port.1: "443/https:8888/http". Default provider (tryDockerInternalNetwork: false) instead needs tsdproxy.autodetect: "true" on each service. Same Unsloth compose as the three-Studios post — host ports commented out, networks and labels in the override.