Post
TSDProxy without exposing containers to the host
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.
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.yaml | matching Compose labels | What TSDProxy dials |
|---|---|---|
tryDockerInternalNetwork: false (default) | add tsdproxy.autodetect: "true" | probes until the container IP on the bridge answers |
tryDockerInternalNetwork: true | omit tsdproxy.autodetect | prefers 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.
# ./config/tsdproxy.yaml — default
docker:
local:
host: unix:///var/run/docker.sock
targetHostname: host.docker.internal
tryDockerInternalNetwork: false
defaultProxyProvider: default
# 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.
# ./config/tsdproxy.yaml — prefer container IP
docker:
local:
host: unix:///var/run/docker.sock
targetHostname: host.docker.internal
tryDockerInternalNetwork: true
defaultProxyProvider: default
# 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:
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.