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.
|
||
|---|---|---|
| .githooks | ||
| .kilo/plans | ||
| tests | ||
| .gitignore | ||
| compose.yaml | ||
| nextcloud.env.example | ||
| pyproject.toml | ||
| README.md | ||
| service.env.example | ||
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_STATEis 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
+insecureflag (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-layerHost-header check. For a backend that validates the hostname itself (like Nextcloud'strusted_domains), an unrecognized tailnet hostname in the Host header still produces a 400 / "Trusted domain error" even with+insecureset. - So a Host-checking app behind
tailscale serveis 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):
- Keep
tailscale serveas the tailnet front and add the tailnet hostname to the backend'strusted_domains(small config change; the backend then "knows" the name). - 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:
TS_HOSTNAME=nextcloud, nothomeassistant. The namehomeassistantis already claimed by HA's own tailscale sidecar. Two nodes can't share a tailnet hostname — the second one will fail to auth.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 plainhttp://when the backend is unencrypted.- 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.