Exposing a Service Over Tailscale
giving one container its own tailnet address, without a public port-forward and without exposing the whole LAN with a subnet router.
wanted a self-hosted service reachable by people who already use my tailnet for something else, without punching a hole in the firewall for it. tailscale makes this genuinely easy — the only real friction was one container-specific setup step that isn’t obvious the first time.
two patterns, pick one
| pattern | blast radius | when to use it |
|---|---|---|
| per-container client | just that one service | default choice — exposes exactly what you mean to |
| subnet router | the entire LAN/VLAN it advertises | only if you actually want every device on that network reachable |
per-container is the one i’d default to almost every time. a subnet router is more convenient if you’re exposing a lot of services at once, but it’s a bigger blast radius for what was, in my case, one service.
if you’re weighing “should my whole hypervisor just join the tailnet” as a bigger future move — that’s a real, separate decision, and a bigger one. don’t let it block exposing the one service you actually need reachable today. the per-container client and a future whole-host integration aren’t mutually exclusive; you can do the small thing now and the bigger thing later without conflict.
if it’s a container: TUN device first
the tailscale client needs /dev/net/tun, which an unprivileged container doesn’t have by default. i went looking for a toggle for this in my hypervisor’s UI and there isn’t one — it’s a conf-file edit, done from the host, not inside the container itself.
worth checking first: some container templates already enable nesting/keyctl at creation time (common on templates built for running Docker or similar inside the container), visible on the container’s own options/features page. that’s unrelated to TUN passthrough specifically — don’t assume having those already means TUN is covered too, it isn’t. it’s a genuinely separate flag.
- shell into the node/host directly, not the container’s own console
- edit the container’s config file
- add:
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
(10:200 is the standard Linux major:minor for /dev/net/tun — stable across kernel versions, not specific to any one hypervisor version.)
- restart the container — conf changes don’t apply to one that’s already running
- verify inside the container:
ls -l /dev/net/tunshould show a character device, not “no such file or directory”
don’t assume your hypervisor’s UI has a checkbox for this just because an old guide says so. check the actual live options page for your version first — mine didn’t have one, despite what i’d read.
install and join
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
prints a login URL — open it, sign in with the same account your other tailnet devices use, approve the node.
running a self-hosted control server instead of tailscale.com’s default? add --login-server=https://your-server-url. otherwise plain tailscale up is correct — and don’t point --login-server at your tailnet’s own MagicDNS domain by mistake, that’s not a control-server URL, the command will just hang waiting on something that isn’t listening.
find it, share it, verify it
- note the tailnet IP or MagicDNS name (
tailscale ipfrom inside the container) - share
http://<tailnet-address>:<port>with whoever needs it - optional:
tailscale serveproxies it over HTTPS with a clean hostname instead of rawIP:port
then check the boring stuff: the service is reachable from another device already on the tailnet, the original LAN URL still works locally (this only adds access, doesn’t remove it), and a device not on the tailnet can’t reach the new address.
one last thing — if the service’s LAN address is referenced anywhere by IP, pin it as a static reservation so it doesn’t drift. a floating DHCP lease that happens to be stable today isn’t guaranteed to stay that way, and “why did the bookmark stop working” is an annoying way to find that out.
where this went for me
i ran this for a while — one tailscale client per container — and then replaced all of it with a cloudflare tunnel. the tailnet approach is genuinely fine: if everyone who needs the service is already on your tailnet, it’s fewer moving parts than a tunnel. it stopped fitting once i wanted to share things with people who weren’t on it — handing someone a hostname they open in a browser beats asking them to enrol a device first. both patterns are valid; pick on who your users are, not on which is “better”.