Standalone Tailscale proxy node: one generic compose file that publishes an arbitrary internal service on the tailnet via tailscale serve. Per-service .env drives hostname, target, and upstream scheme.
Find a file
Mark Whaite 073c39b5d3 test: add pre-commit hook smoke tests so verify gate passes
The org-wide verify fanout runs pytest/npm/cargo unconditionally on every
repo. This config-only repo had no test harness, so pytest exited 5
('no tests collected') and the gate failed despite the pre-commit hook
itself being correct.

Add a minimal pyproject.toml with pytest config and two trivial smoke
tests that verify the new .githooks/pre-commit script parses and exits
cleanly on a clean tree. Plan checklist updated to reflect the new
verification path.
2026-09-14 09:26:27 -07:00
.githooks githooks: add universal pre-commit guard (Mark/tailscale-proxy#1) 2026-09-14 09:20:54 -07:00
.kilo/plans test: add pre-commit hook smoke tests so verify gate passes 2026-09-14 09:26:27 -07:00
tests test: add pre-commit hook smoke tests so verify gate passes 2026-09-14 09:26:27 -07:00
.gitignore test: add pre-commit hook smoke tests so verify gate passes 2026-09-14 09:26:27 -07:00
compose.yaml compose: forward serve vars into container env 2026-08-19 15:46:54 -07:00
nextcloud.env.example docs: add Nextcloud worked example env + podman-compose deploy + trusted-domain caveat 2026-08-22 08:47:34 -07:00
pyproject.toml test: add pre-commit hook smoke tests so verify gate passes 2026-09-14 09:26:27 -07:00
README.md docs: note transparent-proxy / Host-rewrite improvement + why +insecure cannot fix Host-sensitive backends 2026-08-24 11:11:09 -07:00
service.env.example Add generic tailscale proxy template (compose.yaml + per-service env example + README) 2026-08-17 08:19:42 -07:00

Tailscale Proxy Node

A standalone Tailscale node that publishes an arbitrary internal service on your tailnet via tailscale serve. One generic compose file. Per-service .env.

This is not a sidecar — the proxy and the service live on separate network islands. The node's only jobs are: join the tailnet, then forward tailnet traffic to whatever service you point it at.

Topology

tailnet devices  ⇄  [tailscale proxy node]  ⇄  BACKEND_ADDR (arbitrary service)
                        one node per service

Reuse

Copy the repo, then per service create a small .env (see service.env.example and nextcloud.env.example) and deploy. Nothing else changes between services.

docker compose --env-file <service>.env up -d

On a rootless-podman host (no Docker daemon socket), use podman-compose instead — the legacy Python docker-compose 1.29 client fails against the podman socket with a PermissionError. podman-compose drives podman directly and honors network_mode: host.

podman-compose --env-file <service>.env -f compose.yaml up -d

One tailscale node = one tailnet hostname = one auth key. To publish a second service, copy one of the example envs to a new name, change TS_HOSTNAME, TS_SERVE_TARGET, and drop in that service's own auth key. Never commit the populated <service>.env — it contains a live tailnet auth key. The .gitignore already enforces this for *.env (the *.example files are allowed through).

.env variables

Variable Meaning
TS_AUTHKEY Auth key for this node. One key per service.
TS_HOSTNAME Tailnet hostname for this node, e.g. grafana.
TS_SERVE_TARGET Backend address with scheme, e.g. http://10.0.0.15:8080. See below.
TS_SERVE_PORT Tailnet-facing port, default 443.
TS_SERVE_MODE https (default) or tcp.

TS_SERVE_TARGET scheme decides the proxying behavior

Upstream Set TS_SERVE_TARGET to
plain HTTP internal service http://10.0.0.15:8080
HTTPS with self-signed cert https+insecure://10.0.0.15:8443
HTTPS with a real/trusted cert https://10.0.0.15:8443
raw TCP passthrough set TS_SERVE_MODE=tcp, target = 10.0.0.15:5432

https+insecure terminates TLS on the tailnet-facing leg with Tailscale's magic-DNS Let's Encrypt cert, then dials the backend over TLS without verifying its (self-signed) certificate. It does not disable encryption — it only skips peer-cert verification for that one hop.

Why serve runs after up (not via TS_SERVE_CONFIG)

Tailscale v1.94+ crashes in a restart loop when TS_SERVE_CONFIG is combined with TUN mode. So the container does not bake serve into env config. It boots the daemon, waits (authed, with retry) for the node to come online, and only then runs a single tailscale serve command. That keeps https+insecure and the mode switch as plain command arguments too.

Reachability requirement

Because the backend is an unrelated host, the container must be able to route to it. The compose uses host networking so the node can reach any LAN service the host can. If the backend is on a different subnet, the host needs a route (or the service needs its own network attached to the node).

Auth key hygiene

  • Reusable (not ephemeral) key so restarts don't re-auth.
  • One key per node/service — a revoked key takes down only that service.
  • TS_STATE is a named volume so node identity and serve config persist across container re-creation.

Potential future improvement: transparent proxying / Host-header rewriting

tailscale serve (https mode) is a Layer-7 reverse proxy: it terminates TLS on the tailnet-facing leg with Tailscale's valid cert, then opens a fresh hop to the backend carrying the client's original Host header unchanged.

Two consequences:

  • The +insecure flag (e.g. https+insecure://...) only skips backend TLS peer-cert verification at the transport layer. It does not and cannot influence the backend's application-layer Host-header check. For a backend that validates the hostname itself (like Nextcloud's trusted_domains), an unrecognized tailnet hostname in the Host header still produces a 400 / "Trusted domain error" even with +insecure set.
  • So a Host-checking app behind tailscale serve is never fully "transparent": it sees and rejects the tailnet hostname unless that name is added to its allowlist.

To make the backend genuinely never see the tailnet hostname, terminate the tailnet leg and forward to the backend with the Host header rewritten to a name the backend already trusts (e.g. homeassistant.local). That is a Host-rewriting reverse-proxy hop (nginx/caddy), which tailscale serve does not do on its own. This repo's compose only passes TS_SERVE_TARGET straight to tailscale serve, so it currently offers no rewrite.

If ever needed, the two candidate directions (not mutually exclusive):

  1. Keep tailscale serve as the tailnet front and add the tailnet hostname to the backend's trusted_domains (small config change; the backend then "knows" the name).
  2. Insert a Host-rewriting reverse proxy (nginx/caddy) in front of the backend hop — Nextcloud never sees the tailnet name; no backend allowlist change.

Worked example: HA Nextcloud (nextcloud.env.example)

nextcloud.env.example ships in the repo as a fully-filled template for the common case of fronting the Home Assistant Nextcloud add-on.

client --https--> nextcloud.tailXXXXXX.ts.net:443 (Tailscale LE cert)
                  --https+insecure--> homeassistant:8099 (self-signed)

Three things to notice:

  1. TS_HOSTNAME=nextcloud, not homeassistant. The name homeassistant is already claimed by HA's own tailscale sidecar. Two nodes can't share a tailnet hostname — the second one will fail to auth.
  2. TS_SERVE_TARGET=https+insecure://homeassistant:8099. The HA add-on uses a self-signed cert, so we skip peer verification on that hop. TLS is still on; we're just not validating the backend cert. Use plain http:// when the backend is unencrypted.
  3. Bare hostname (homeassistant), not an IP. HA's tailnet IP can change. Bare tailnet hostnames resolve via magic-DNS, so the proxy env doesn't need to know or care.

To deploy:

cp nextcloud.env.example nextcloud.env
# edit nextcloud.env: replace TS_AUTHKEY with a fresh reusable key
podman-compose --env-file nextcloud.env -f compose.yaml up -d

The trusted-domain caveat (Hostname-sensitive backends)

A working proxy is not the same as a working app. tailscale serve terminates TLS and forwards the request, but it does not rewrite the Host: header. So a request that arrives at nextcloud.tailXXXXXX.ts.net will reach the backend carrying Host: nextcloud.tailXXXXXX.ts.net.

Some apps enforce a list of hostnames they trust. If nextcloud.tailXXXXXX.ts.net isn't in that list, requests fail with one of:

  • Nextcloud / ownCloud: 400 {"error":"Trusted domain error."}
  • Some keycloak setups: 421 / invalid_request
  • HTTP-only internal services that bind by hostname: connection refused on the wrong vhost

The fix lives on the backend, not the proxy. Add the tailnet hostname to the backend's trusted-host list. For Nextcloud:

// config.php
'trusted_domains' =>
  array (0 => 'homeassistant.local', 1 => 'nextcloud.tailXXXXXX.ts.net'),

Verify a proxy failure vs an app-side trusted-domain failure by hitting a "doesn't care about hostname" path first (e.g. Nextcloud's /status.php) and comparing to the backend directly. /status.php returns 200 even when the real app is rejecting on trusted_domains, which points the finger at the proxy.