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