vinnymarquez.dev/tutorials/exposing a service over tailscale
August 8, 20264 min readcheat sheet#homelab#networking#tailscale

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.

  1. shell into the node/host directly, not the container’s own console
  2. edit the container’s config file
  3. 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.)

  1. restart the container — conf changes don’t apply to one that’s already running
  2. verify inside the container: ls -l /dev/net/tun should 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.

LXC conf file with TUN passthrough lines

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

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”.

▌ comments