feat(share): Tailscale serve stack to share internal services with external tailnet users

Adds stacks/share/ — a dedicated stack of userspace Tailscale 'serve'
nodes that expose individual internal services to external tailnet
users (a friend on his own tailnet) over each node's 100.x identity.
No public exposure, no LAN access, immune to the friend's home-subnet
addressing. First node ts-roms serves RomM at roms-share.<tailnet>.ts.net
-> romm:8080. One reusable tag:share auth key + one ACL rule cover every
node; adding a service is a serve-<svc>.json + a copied service block.

roms stack reverted to a pointer comment.
This commit is contained in:
ginnoir
2026-06-06 22:52:15 -05:00
parent dc0a4eb58f
commit 2aa8122cd3
4 changed files with 78 additions and 0 deletions
+4
View File
@@ -15,6 +15,10 @@
# The labdata/romm/* targets are auto-created by Docker as root on first start.
#
# Only romm joins `edge` (Caddy proxies romm:8080); romm-db stays on `roms` only.
#
# To share RomM with an EXTERNAL tailnet user (a friend on his own tailnet), see
# the dedicated `share` stack (stacks/share/), which runs a Tailscale `serve`
# node joined to this `roms` network.
services:
romm:
+45
View File
@@ -0,0 +1,45 @@
# share stack — Tailscale `serve` nodes that expose individual INTERNAL services
# to EXTERNAL tailnet users (a friend on his own tailnet) over each node's 100.x
# CGNAT identity. No public exposure, no LAN access, and immune to the friend's
# home-subnet addressing (a node IP can never collide with his 192.168.1.0/24).
#
# Each service to share = one userspace Tailscale container that `tailscale serve`s
# straight to that service's backend, so the container joins that service's PRIVATE
# network (roms, foundry, media, ...) — NOT edge/Caddy.
#
# Shared, set-once-for-all plumbing:
# - Auth: ONE reusable, NON-ephemeral, TAGGED (tag:share) auth key in stack.env
# authenticates EVERY node here. Tagged nodes never expire.
# - ACL : ONE rule { src: [friend@…], dst: ["tag:share:443"] } governs them all.
# - Admin (one-time): enable MagicDNS + HTTPS certificates; add
# "tagOwners": { "tag:share": ["autogroup:admin"] }.
#
# To share a NEW service:
# 1. add a `serve-<svc>.json` (Proxy -> http://<container>:<port>)
# 2. add a `ts-<svc>` service block below (copy ts-roms), join the svc network
# 3. add that network under `networks:` as external
# 4. push, then admin console -> Machines -> <svc>-share -> Share -> friend email
services:
ts-roms:
image: tailscale/tailscale:latest
container_name: ts-roms
hostname: roms-share
restart: unless-stopped
networks: [roms]
env_file:
- stack.env
environment:
- TS_HOSTNAME=roms-share
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=true
- TS_SERVE_CONFIG=/config/serve.json
- TS_EXTRA_ARGS=--advertise-tags=tag:share
volumes:
- /config/share/ts-roms:/var/lib/tailscale
- ./serve-roms.json:/config/serve.json:ro
networks:
roms:
name: roms
external: true
+16
View File
@@ -0,0 +1,16 @@
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": {
"Proxy": "http://romm:8080"
}
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
# share stack secrets — Tailscale `serve` nodes.
# Read via env_file (no ${VAR} interpolation), so this git stack needs no
# Portainer UI env vars. See memory portainer-env-interpolation.
TZ=America/Chicago
# ── Tailscale auth (shared by EVERY ts-* node in this stack) ──────────────────
# ONE reusable, NON-ephemeral, TAGGED auth key from:
# https://login.tailscale.com/admin/settings/keys ("Generate auth key")
# - Reusable: ON - Ephemeral: OFF - Tags: tag:share
# Prereq: add "tagOwners": { "tag:share": ["autogroup:admin"] } to your ACLs
# first, or key generation rejects the tag. Tagged nodes never expire, so once
# the nodes have joined this key is no longer needed (state lives in the volumes).
TS_AUTHKEY=tskey-auth-kGQ7kgFizL11CNTRL-HWpbE5TudYTaV2ss4LDmYT7sZ8EddXVH