Runbook: per-service Tailscale Funnel shares¶
Give someone temporary access to specific homelab services when they have no Tailscale client and you refuse to open a port. Funnel reverse-proxies out through Tailscale's edge over the existing outbound WireGuard session — no port forwarding, no firewall change, no client install on their end.
The stack is the switch:
stacks/funnel-share/— one compose profile per share, so exposure is always per-service and deliberate. Companions:docs/runbooks/tailscale.md,docs/runbooks/dns.md.
0. Never run tailscale funnel or tailscale serve on av ⚠️¶
This is the whole reason the sidecar stack exists. Serve and Funnel take over the port they listen
on for traffic arriving at that node's own Tailscale addresses. On av that port is 443 — and
av's tailnet address is exactly what every *.fmm.house name publishes as its fd7a AAAA, so
off-LAN and shared-in clients can reach Traefik.
Run tailscale funnel 3310 on av and those clients stop reaching Traefik. They reach tailscaled,
which terminates TLS with the ts.net certificate, sees SNI ha.fmm.house, has no matching cert,
and drops the connection. The browser reports no certificate information and the mobile apps
report a bare SSL error. It looks intermittent because Happy Eyeballs sometimes picks the routed ULA
(which still reaches Traefik) and sometimes picks fd7a (which does not).
This happened on 2026-08-02 and took down *.fmm.house for tailnet clients. Two traps made it
hard to see:
- You cannot detect it from
av.curl --resolve ha.fmm.house:443:100.98.53.7from av returns200, because local traffic never traverses the tailnet ingress path where the interception happens. That test looks like exoneration and proves nothing. Test from another tailnet device. - A second, unrelated bug was live at the same time — a stale
AAAA(seedns.md, 2026-08-02). Finding a real defect is not the same as finding the one causing the symptom.
The rule: tailscale serve status on av must always print No serve config. Every share gets
its own node via stacks/funnel-share/, never av itself.
1. Why a node per service¶
A single tailnet node gets one ts.net hostname and Funnel may only listen on 443, 8443, and
10000. Five services do not fit in three ports, and path-prefix routing (--set-path /ha) is not a
way out: Home Assistant flatly refuses to run under a subpath, and Forgejo would need its global
ROOT_URL rewritten, which breaks git.fmm.house for everyone else.
So each share runs its own tailscale/tailscale sidecar container — its own node, its own hostname,
Funnel on 443, serving the app at the root path. The sidecars proxy to the existing containers
over the shared proxy network. Nothing about the proxied stacks changes and Traefik keeps serving
the same services on *.fmm.house for the tailnet.
| Share | Public hostname | Proxies to | Also served at |
|---|---|---|---|
| docs | docs.anaconda-perch.ts.net |
http://mkdocs:8000 |
docs.fmm.house |
| git | git.anaconda-perch.ts.net |
http://forgejo:3000 |
git.fmm.house |
| ha | ha.anaconda-perch.ts.net |
http://172.30.0.1:8123 |
ha.fmm.house |
| ma | ma.anaconda-perch.ts.net |
http://172.30.0.1:8095 |
ma.fmm.house |
| www | www.anaconda-perch.ts.net |
http://homarr:7575 |
www.fmm.house |
| mune | mune.anaconda-perch.ts.net |
http://mune-prod-web-1:3000 |
mune.fmm.house |
ha and ma are host-networked, so the sidecars reach them through the proxy bridge gateway
(172.30.0.1) exactly as Traefik does.
mune is not part of this repo — it lives in ~/mune as its own compose project, and the
sidecar targets its prod container by name (mune-prod-web-1), reachable only because that
container joins the shared proxy network. That name is the coupling: rename the project or
re-index the service and serve/mune.json needs updating. Its dev stack publishes to
127.0.0.1:3200 — loopback-only, so a container on proxy cannot reach it and it is not funnelable
without changing how it publishes.
mune auth is origin-pinned. Its prod env hardcodes
BETTER_AUTH_URL,PASSKEY_RP_ORIGINandAPP_URLtohttps://mune.fmm.house. Page serving is fine under thets.nethostname (redirects come back relative —/r/main— and resolve200), but passkey login cannot work from another origin: WebAuthn binds credentials to the origin by specification, and no proxy config changes that. Email/password sign-in may also be rejected unless thets.netorigin is added to better-auth'strustedOrigins. Sharemunefor browsing; assume auth needs testing.
2. One-time prerequisites (admin console — not automatable from this repo)¶
There is no Tailscale API credential in this repo, so these are console-only.
- DNS → HTTPS Certificates → Enable. Tailnet-wide. Verify:
tailscale status --jsonshows a non-nullCertDomains. - Access controls (admin/acls/file) — one text
box holding the whole tailnet policy in HuJSON (JSON plus comments and trailing commas).
tagOwnersandnodeAttrsare top-level keys, siblings ofacls, not nested inside it.
Search the box for funnel first. Tailscale ships a default funnel grant targeting
autogroup:member, which hands every member's own devices Funnel capability — that is how av
had it, not because anyone granted it deliberately. Add a second entry for the tag and keep
the default. Tagged nodes are owned by the tailnet rather than a user, so autogroup:member
does not cover them; the new entry is what actually lets the shares funnel.
{
"tagOwners": {
"tag:funnel-share": ["autogroup:admin"],
},
"grants": [ // this tailnet's syntax; older policies use "acls".
{"src": ["*"], "dst": ["*"], "ip": ["*"]}, // either way, leave it alone
],
"nodeAttrs": [
{"target": ["autogroup:member"], "attr": ["funnel"]}, // Tailscale default — kept
{"target": ["tag:funnel-share"], "attr": ["funnel"]}, // the share sidecars
],
}
No grant/acl rule is needed for the sidecars. They never send traffic over the tailnet: they
receive Funnel traffic from Tailscale's ingress and proxy to Docker bridge addresses, which
tailnet policy does not govern. tagOwners only declares who may apply the tag, which is what
stops an arbitrary node from claiming Funnel rights by advertising it.
autogroup:member is kept deliberately, so ad-hoc funnels on personal devices still work.
The tradeoff is that nothing in the tailnet stops a funnel being started on av — §0 stays a
rule someone has to follow rather than one the policy enforces. funnel-share-prereqs.sh checks
tailscale serve status on av for exactly this reason; that check is the guard. If you later
decide the convenience isn't worth it, drop the autogroup:member entry and the footgun goes
away entirely.
- Auth key — Settings → Keys → Generate auth key. Reusable, not ephemeral, tagged
tag:funnel-share. Put it instacks/funnel-share/.envasTS_AUTHKEY; see.example.env.
Non-ephemeral is the deliberate choice, because the share URL must outlive the container. An
ephemeral node is removed from the tailnet when it goes offline; reopen the share before Tailscale
has reaped the old entry and the new node collides on hostname and registers as git-1,
changing a URL you already gave someone. Non-ephemeral plus the persisted ./data/<svc> state dir
means the node keeps its identity across any number of open/close cycles. The cost is that closed
shares leave dormant nodes in the machine list — retire them manually when a share is done for
good (§3). Key expiry only limits enrolling new nodes; tagged nodes already enrolled do not
expire.
3. Opening and closing a share¶
The nodes are permanent; the public route is what expires. Each share sits behind its own
compose profile, so opening one is always deliberate and per-service. A bare docker compose up -d
starts nothing — that is on purpose, so a reflexive up -d can never publish all four at once.
cd stacks/funnel-share
docker compose --profile all up -d # open every share, one command
docker compose --profile git up -d # or just one
docker compose --profile docs --profile ha up -d # or a subset
docker compose logs -f ts-git | head -30 # watch it register + get its cert
docker compose exec ts-git tailscale funnel status
docker compose --profile git down # close that share; node persists
docker compose down # close everything currently running
Every service carries two profiles — its own name and all — so one command opens the lot while
individual shares stay independently controllable. A bare docker compose up -d still starts
nothing: exposure is always something you named.
Closing a share stops the container, so nothing answers the public URL. The tailnet node survives
— which is what you want for a share you may reopen: identity lives in ./data/<svc>, so the
ts.net hostname stays the same and a URL you handed out a month ago still works when you reopen.
For a share that is finished for good, retire the node too (Machines → ⋯ → Remove); otherwise it
lingers as a Funnel-capable node that returns the moment its profile is started again. And if you
want a hard tailnet-side stop covering every share at once, delete the nodeAttrs entry granting
funnel to tag:funnel-share — that revokes the capability regardless of what Docker is running.
After opening, verify the primary path is untouched — this is the check that would have caught the 2026-08-02 outage:
tailscale serve status # on av: MUST print "No serve config"
docker compose exec ts-ha tailscale serve status # the sidecar's own funnel — this one is expected
Then load any *.fmm.house name from another tailnet device (not from av — see §0). If a
sidecar ever reports a serve config on av itself, something is misconfigured; stop and fix it
before handing out a URL.
Prefer down over stop: restart: unless-stopped brings a merely-stopped container back when the
Docker daemon restarts, which would silently reopen a share you thought was closed.
4. You cannot test a Funnel URL from av¶
From on-tailnet, MagicDNS resolves these hostnames to their own tailnet IPs rather than to
Tailscale's public ingress, so a local curl does not exercise the path an outsider takes. (Testing
ascertain-verse itself is worse: :443 lands on Traefik, which answers with a *.fmm.house
cert and a TLS name mismatch that looks like a Funnel failure and is not.) Resolve against the
public ingress instead:
dig +short @1.1.1.1 docs.anaconda-perch.ts.net # → Tailscale ingress IPs
curl --resolve docs.anaconda-perch.ts.net:443:<ingress-ip> \
https://docs.anaconda-perch.ts.net/ -o /dev/null -w '%{http_code}\n'
5. What does not work¶
game.fmm.house(moonlight-web) cannot be funnelled. Funnel is a TCP proxy over TLS; the stream is WebRTC media on UDP 40000–40100, published straight to the host and deliberately not proxied.WEBRTC_NAT_1TO1_HOSTis also pinned to av's tailnet IP (100.98.53.7), which an outsider cannot route to. The web UI would load and every stream would fail. Making it work means forwarding that UDP range on the router and advertising a public IP — precisely the port-opening Funnel exists to avoid. For remote gaming, the recipient installs Tailscale (seedocs/retro-box/).- git-over-SSH. SSH is host port
2222; Funnel serves only 443/8443/10000. HTTPS clone against thets.netURL works fine — git ignoresROOT_URL. - Custom domains. Funnel can only use names in your tailnet's domain. CNAME-ing
*.fmm.houseat ats.netname fails the TLS handshake: the ingress has no certificate forfmm.house, and upstream (#11563) is still open. If you want real*.fmm.housenames exposed with proper auth, that is a Cloudflare Tunnel + Access project and a nameserver move off Porkbun — readdocs/runbooks/dns.mdfirst, the zone is load-bearing.
6. Exposure — read before opening¶
- Bandwidth relays through Tailscale's DERP servers. Fine for docs and a source zip; rude for media libraries.
- The hostnames become public. Once certs are issued they appear in Certificate Transparency logs. Treat every share URL as discoverable; obscurity protects nothing.
- Home Assistant is the serious one. Funnelling
haputs HA's login page on the public internet. Turn on MFA before doing it, and prefer closing this share promptly. HA'sconfiguration.yamlalready setsuse_x_forwarded_forwithtrusted_proxies: 172.30.0.0/24, which covers the sidecar, so HA sees real client IPs and its brute-force IP-ban logic works correctly rather than banning the proxy. - Music Assistant exposes your library and playback control; it is not a hardened front end.
- The auth key enrolls Funnel-capable nodes. Rotate it after any share where it may have leaked.
7. Forgejo specifics¶
7a. REQUIRE_SIGNIN_VIEW outranks per-repo visibility¶
REQUIRE_SIGNIN_VIEW is deliberately false — public repos are browsable and downloadable
without an account, and that is the intended standing configuration, not a share-mode flag to flip.
No account needs creating to share a repo; you only open the git sidecar.
The consequence is that the setting is instance-wide and outranks per-repo visibility, so per-repo
is_private is the only thing protecting anything, and reachability is the other half of the
control. Normally git.fmm.house is tailnet-only, so "anonymous" means "anonymous to your tailnet".
The moment the git share is up, it means anonymous to the internet.
So the audit belongs before opening the share, not before flipping a flag:
sqlite3 "file:stacks/forgejo/data/gitea/gitea.db?mode=ro" \
"SELECT owner_name, name, is_private FROM repository;"
As of 2026-08-02 the instance holds exactly one repo, vaporware-studio/tpm (public), one admin
user, and one org — so opening git exposes that repo and nothing else. Mark a repo private
if it should survive a share; that is the switch, not the sign-in wall. DISABLE_REGISTRATION=true
holds regardless, so nobody can self-register an account.
Archive URLs use the repo's default branch: tpm's is master, not main, and the wrong ref
404s — /vaporware-studio/tpm/archive/master.zip.
7b. ROOT_URL is deliberately left alone¶
ROOT_URL stays pinned to https://git.fmm.house/. The worry is that Forgejo generates absolute
links from it and an outsider following one hits a tailnet-only name. Tested against the running
container with a mismatched Host header — in practice it does not bite:
| Surface | Behavior under a foreign hostname |
|---|---|
Page render (/, /user/login) |
200 |
| Redirects (gated repo → login) | Location: /user/login — host-relative |
Login form action, assets, nav links |
relative (AppSubURL is empty at root install) |
Archive endpoint /{owner}/{repo}/archive/{ref}.zip |
plain GET; no ROOT_URL dependency |
Only three absolute references survive in the markup, none load-bearing: window.config.appUrl, an
og:url meta tag, and the string behind the cosmetic incorrect_root_url banner.
Warn them about that banner. Forgejo detects the hostname mismatch client-side and shows "This
Forgejo instance is configured to be served on https://git.fmm.house/. You are currently viewing
Forgejo through a different URL." Harmless, downloads work regardless, but it reads like a security
warning to someone who does not know the setup. Flipping ROOT_URL to silence it would relay all
your own git traffic over DERP instead. Not worth it.
There is deliberately no loopback publish on the Forgejo container any more. A 127.0.0.1:3310
port existed briefly as a target for a one-off tailscale funnel 3310 on av; that approach is
retired for the reason in §0, and the port was removed with it. The git sidecar reaches Forgejo
at http://forgejo:3000 over the proxy network, so no host port is needed.