Skip to content

Deployment Notes

Alex Logvin edited this page May 28, 2026 · 5 revisions

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.


Plex flags tailnet clients as "remote"

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:

  1. Plex's auto-detected LAN interface
  2. The LAN Networks setting 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.

Option A: free-tier workaround (no Plex Pass required)

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.

  1. Run the subnet router on the same LAN as the Plex server (already true if both are on the same Docker host)
  2. Leave --snat-subnet-routes at its default of true (don't set it to false)
  3. 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.

Option B: explicit LAN Networks config (Plex Pass may be required)

If your Plex Pass status allows access to the LAN Networks setting:

  1. 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.

    Plex Settings → Network → LAN Networks with 100.64.0.0/10 added

  2. Settings → Network → Custom server access URLs (optional but recommended) Add http://<plex-server-tailnet-ip>:32400. Get the tailnet IP with tailscale status on 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.

  3. Set --snat-subnet-routes=false on 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.


Tailscale Serve strips the matched URL prefix before proxying

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.


Tailscale Serve's Text handler serves text/plain

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.

  1. Create index.html with the desired markup
  2. Mount it into the container alongside serve.json:
    volumes:
      - ./index.html:/config/index.html:ro
  3. 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.


HTTPS Certificates enabled mid-deploy: container needs a restart

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 app

Cert 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.


Bridging Serve to an existing Docker network

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.


Clone this wiki locally