-
Notifications
You must be signed in to change notification settings - Fork 0
Deployment Notes
Living document for real-world gotchas, edge cases, and seams that aren't obvious from the main README. Add to this as you operate the stack and find things that surprise you.
Each entry follows the same shape: Symptom, Cause, Fix.
Symptom A device connects to Plex over the tailnet and Plex treats it as a remote client: prompting for the Remote Pass, applying the Remote Streaming bitrate cap, or refusing direct play. Same client is on the same physical LAN as the Plex server. Frustrating.
Cause Plex decides local-vs-remote by comparing the client's source IP against two things:
- Plex's auto-detected LAN interface
- The
LAN Networkssetting in Plex's Network configuration (a CIDR list of additional ranges to treat as local)
When a tailnet client connects to Plex via Tailscale, Plex sees the
source IP as something in 100.64.0.0/10 (Tailscale's CGNAT range).
That range isn't in Plex's LAN Networks list by default, so Plex
flags the connection as remote.
This isn't a Tailscale bug, it's a Plex configuration default. Plex doesn't know what Tailscale is. From its perspective, an unfamiliar private IP range showed up and it played it safe.
Fix
There are two paths depending on whether you have Plex Pass.
Lean on the subnet router's default SNAT behavior. By default, Tailscale subnet routers source-NAT outbound traffic, so tailnet clients appear to Plex as coming from the subnet router's LAN-side IP — which is already in Plex's auto-detected LAN range.
- Run the subnet router on the same LAN as the Plex server (already true if both are on the same Docker host)
- Leave
--snat-subnet-routesat its default oftrue(don't set it tofalse) - Verify Plex's auto-detected LAN includes the subnet router's LAN IP (Settings → Network in the Plex admin UI)
Trade-off: Plex loses visibility into the original client IP — every tailnet connection appears to come from the subnet router. Fine for a homelab; matters for customers who need per-client audit logging.
If your Plex Pass status allows access to the LAN Networks setting:
-
Settings → Network → LAN Networks Add
100.64.0.0/10. This is the full Tailscale CGNAT range. Any connection from a tailnet device will now be treated as local.
-
Settings → Network → Custom server access URLs (optional but recommended) Add
http://<plex-server-tailnet-ip>:32400. Get the tailnet IP withtailscale statuson the Plex host. This makes plex.tv hand out the tailnet path explicitly to clients, so they can find Plex via Tailscale even when LAN discovery fails. -
Set
--snat-subnet-routes=falseon the subnet router so Plex sees the original tailnet client IP (which is now in its LAN Networks list)
Restart Plex Media Server after the change. On mobile clients, sign out and back in to force fresh server discovery.
Why this matters beyond Plex The same pattern shows up with any application that has its own notion of "local network" enforcement: bandwidth caps, auth bypass, direct-play behavior, multicast-only features. Tailscale moves these clients onto a private mesh, but the application still has to be told that the new IP range counts as local. The seam between Tailscale and the applications it secures is where most of the real-world configuration friction lives.
Symptom
You've set up Tailscale Funnel to expose Plex publicly so people without
Tailscale can stream from your CGNAT'd home server. Funnel reports
running, the *.ts.net URL resolves, but Plex clients can't connect
remotely. The Custom server access URLs field keeps "going back" to
:32400 after you save it as something else, or the field just doesn't
work and you can't tell why.
Cause
Funnel terminates the public HTTPS connection on port 443, then
proxies internally to Plex on 32400 on the local host. Plex's
"Custom server access URLs" field needs the EXTERNAL URL that clients
will hit (:443), not the INTERNAL port that Plex itself listens on
(:32400). Putting :32400 in the field sends external Plex clients
to a port that the Funnel relay isn't listening on, and the connection
fails silently.
The reason Plex appears to "reset" the field to :32400 is that
Plex's Remote Access feature, if enabled, periodically overwrites the
URL with what it thinks your server is reachable at — usually using
the actual Plex port. As long as Remote Access is enabled, your manual
edits get clobbered.
Fix
- Disable Plex's built-in Remote Access (Settings → Remote Access → Disable). Funnel becomes the sole remote-access path; you don't want Plex fighting it.
-
Set Custom server access URLs to
https://<host>.<tailnet>.ts.net:443(explicit:443, even though it's the default HTTPS port — Plex's URL handling sometimes requires the explicit port). - Save and restart Plex Media Server. The field should now stick.
Common adjacent failure: two-hop Funnel config
If tailscale funnel status shows something like this:
https://host.tailnet.ts.net:10000 (tailnet only)
|-- / proxy http://localhost:32400
https://host.tailnet.ts.net (Funnel on)
|-- / proxy https+insecure://localhost:10000
That's a broken two-hop configuration: Funnel on 443 → an intermediate
Serve port (10000 here) → localhost:32400. It can happen if you ran
both tailscale serve and tailscale funnel commands at different
times and the configs got layered. Reset and re-do:
tailscale serve reset
tailscale funnel --bg --https=443 localhost:32400
tailscale funnel statusAfter the reset, tailscale funnel status should show ONE handler
going directly from Funnel to http://localhost:32400:
https://host.tailnet.ts.net (Funnel on)
|-- / proxy http://localhost:32400
That's the shape that works.
Symptom
You configure a Serve handler like
"/sonarr/": { "Proxy": "http://sonarr:8989" } and pointing a
browser at https://app.<tailnet>.ts.net/sonarr/ results in an
infinite redirect loop. Browser shows ERR_TOO_MANY_REDIRECTS.
Direct access to Sonarr (bypassing Serve) at
http://127.0.0.1:8989/sonarr/ works fine.
Cause
Serve's Proxy handler matches the prefix, then strips it before
forwarding to the backend. With the match /sonarr/ and a proxy
target of http://sonarr:8989 (no path), the backend receives the
request at /, not /sonarr/. Sonarr (with URL Base = /sonarr)
sees a request at / and generates a redirect to /sonarr/.
Serve strips the prefix again. Loop forever.
Fix Include the same path in the proxy target URL:
"/sonarr/": { "Proxy": "http://sonarr:8989/sonarr/" }Now Serve preserves the path, the backend matches its URL Base, and the request resolves on first hop.
When this applies Any backend that runs under a URL base or path prefix:
- *arr apps with URL Base set
- Jellyfin behind a sub-path
- Any self-hosted app with "base URL" or "HTTP root" configuration
For vanilla web apps that live at root, the proxy target without a path works fine.
This behavior isn't covered in Tailscale's quick-start examples, which all use single-app setups where path preservation doesn't matter. It only surfaces once you have more than one path-prefixed service behind a single Serve hostname.
Symptom
You set up a landing page using a Text handler with inline HTML,
expecting it to render in the browser. Instead, the browser shows
the raw HTML source as plain text.
Cause
Tailscale Serve's Text handler sets Content-Type: text/plain and
provides no way to override it. The handler is intended for simple
string responses (health checks, debug info, "Hello world"-style
endpoints), not styled HTML pages.
Fix
Use a Path handler pointing at an actual HTML file. The Path
handler auto-detects MIME type based on the file extension.
- Create
index.htmlwith the desired markup - Mount it into the container alongside
serve.json:volumes: - ./index.html:/config/index.html:ro
- Reference it in
serve.json:"/": { "Path": "/config/index.html" }
For a richer landing page (multiple assets, JS, etc.), Path can
also point at a directory to serve static files from.
Symptom
You enabled HTTPS Certificates in the admin console after the Serve
container was already running. Curl to https://app.<tailnet>.ts.net/
still returns "Couldn't connect to server" / connection refused on
port 443. Container logs show
serve proxy: ... not able to issue TLS certs, so this will likely not work from boot.
Cause Tailscale Serve evaluates whether HTTPS cert provisioning is available at container startup. If the tailnet's HTTPS Certificates feature was off when the container booted, Serve gives up on TLS initialization and doesn't retry even after the feature is later enabled. The Serve config sits loaded but inactive.
Fix Restart the container so it re-runs the Serve initialization with the new tailnet capability:
docker compose -p <project> restart appCert provisioning takes 5-30 seconds on first start; watch the container logs for "certificate obtained" before testing.
Prevention Enable HTTPS Certificates BEFORE first-time container startup. It's a one-time tailnet-wide toggle and benign to leave on; turn it on during Phase 1 of the deployment rather than discovering you need it during Phase 4.
Symptom
You want Tailscale Serve to proxy to *arr apps that are already
running in production on a different Docker network (e.g., your
existing nginx/swag stack on NGINX_Network), not to fresh demo
containers. Default docker compose up puts the app container on
its own private network where the existing apps aren't reachable.
Cause
Containers can only resolve each other by DNS name within the same
Docker network. The app container starts on plex-private (the
network defined in compose.yml), so it can't see your real sonarr
or radarr containers that live on NGINX_Network.
Fix
Add a docker-compose.override.yml that declares the existing
network as external and attaches the app service to it (in
addition to the private network):
networks:
nginx-net:
external: true
name: NGINX_Network # your real existing network name
services:
app:
networks:
plex-private:
ipv4_address: 172.30.0.2
nginx-net:Then update serve.json to use container names instead of IPs:
"/sonarr/": { "Proxy": "http://sonarr:8989/sonarr/" }Docker's built-in DNS resolves sonarr to the container's IP on
whichever network it shares with the app container.
docker-compose.override.yml is the canonical pattern for
host-specific config; it's automatically merged on top of
docker-compose.yml. Add it to .gitignore if it contains
infrastructure details you don't want in a public template repo.
Why this is useful Production demo with real apps and real data is more impressive than a fresh demo with empty containers. The override pattern lets you keep the public repo as a clean template (anyone can clone and run with fresh containers) while running a real bridged deployment locally for your own use.