Tailscale with Docker: Reach Your Apps Without Any Public Exposure
Part 3 of Securing Remote Access to Your Homelab. Covers apps you've decided should stay private — see Part 1 if you haven't made that call yet.
The setup here is smaller than most guides make it sound, because of one decision that's worth stating up front: Tailscale runs on the host, once — not inside a container, and not once per app. Every container running on that host becomes reachable through the host's single tailnet address, at whatever port it publishes. No sidecar container, no per-app Tailscale config.
Summary
- Install Tailscale on the host (Synology, a NUC, a VM — wherever Docker runs), not inside a container. One install covers every app on that host.
- Each host gets a stable
100.x.y.zaddress, reachable only from other devices on your tailnet. - A container becomes reachable over the tailnet the moment it publishes a port — nothing app-specific to configure.
- A per-container sidecar (
tailscale/tailscaleas its own service) is a real, different pattern — a decision guide at the end covers exactly when it earns its keep over host-level (different ACLs per app, apps that migrate hosts, per-app Funnel/Serve) versus when it's just extra containers for no benefit.
Install Tailscale on the host
This is a host-level install, same as you'd do on a laptop — not a docker run. On a
generic Linux box:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale up prints a login link; open it, approve the device in your Tailscale
admin console, and the host joins your tailnet. Confirm its address:
tailscale ip -4
# 100.x.y.z
That address is now how every other device on your tailnet reaches this host — and everything Docker publishes a port for on it.
On Synology DSM, install via Package Center (search "Tailscale") if it's listed
for your model, or via the community package feed if not — the underlying tailscale up login flow is identical once it's running.
Or: run it in Docker, but with host networking
There's a third option, distinct from both the native install above and the sidecar
pattern later in this post: run the official tailscale/tailscale image with
network_mode: host. This is exactly what one of my own hosts runs, alongside a
handful of natively-installed ones — both are the same "host-level" model, just
packaged differently:
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: nas # the name this host gets on your tailnet
network_mode: host
restart: unless-stopped
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- /path/to/appdata/tailscale:/var/lib/tailscale
devices:
- /dev/net/tun:/dev/net/tun
environment:
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
- TS_EXTRA_ARGS=--accept-dns=false
network_mode: host is the load-bearing line — it drops Docker's normal network
isolation for this one container so it shares the host's network stack directly,
which is what lets it advertise the host itself (not just itself) on the tailnet.
Without it, you'd get a container with its own isolated IP, reachable only from
other containers on the same Docker network — not what you want here. This is a
genuine trade-off, not a free win: that container now has full access to the host's
network interfaces, which is a meaningfully larger grant than a normal app container
gets.
Same result as the native install either way — one tailnet identity for the whole
host — just choose native if you'd rather manage it with your OS's own tools, or
this if you'd rather manage everything through docker compose and see it in the
same place as your other containers.
A container is reachable the moment it publishes a port
There's no extra step. If a container has:
ports:
- "8080:8080"
...it's reachable at http://100.x.y.z:8080 from any device on your tailnet, the
instant the container starts — because that port is bound on the host's network
interfaces, and the host's tailnet interface is one of them. Tailscale doesn't know or
care that Docker exists; from its point of view, it's just another port open on a host
it already trusts.
This is also why nothing here needs a network_mode change or extra Docker
configuration: the mesh operates at the host level, below Docker entirely.
Naming things: give hosts memorable names, not IPs
100.x.y.z isn't something you want to memorize per host. Tailscale's own
MagicDNS (Admin console → DNS → enable MagicDNS) gives every device a name based
on its hostname, so http://homelab:8080 works from any tailnet device without an
/etc/hosts entry anywhere.
If you also run local DNS (AdGuard, Pi-hole) for custom domains on your LAN, know that
the two can conflict: a DNS rewrite that resolves app.example.com to a LAN IP
works fine for LAN clients, but a client that's only reachable over Tailscale (off
your home WiFi, on a different network entirely) needs that name to resolve to the
tailnet IP instead, or the connection has nowhere to go — the name resolves, but
to an address the client can't actually route to. If an app works at home and not
away, this DNS/routing mismatch is the first thing to check, well before assuming
Tailscale itself is broken.
Locking a private app down deliberately
The point of this whole approach is that a private app has no route in other than the tailnet — not "a route that's merely undocumented," an actual absence. Two habits make that real instead of accidental:
- Never add a Cloudflare Tunnel public hostname for it. The tunnel setup from Part 2 is opt-in per app; simply not adding the route is the entire control.
- If it's also reachable on your LAN, that's a second door. Either bind the container's port to the host's tailnet interface specifically, or firewall the LAN path shut if the app has no auth of its own worth trusting to a home network shared with guests, IoT devices, or anything else you don't fully control.
Three ways to run it, two networking models — a real decision
You've now seen three concrete setups: native install, Docker with
network_mode: host, and — coming up — the per-container sidecar. The first
two are really the same networking model (one Tailscale identity per host) worn
differently; the sidecar is a genuinely different model (one identity per app). Which
one is right depends on what you're actually trying to control:
| Native install | Docker, host networking | Per-container sidecar | |
|---|---|---|---|
| Networking model | Host-level | Host-level (identical result) | Per-app identity |
| Setup cost | One install, OS-managed | One container, network_mode: host | One extra container, per app |
| Trust boundary | Whole host shares reachability | Whole host shares reachability | Each app isolated by default |
| ACL granularity | Per host only | Per host only | Per app |
| Follows the app across hosts | No | No | Yes |
| Devices in your admin console | One per host | One per host | One per app |
Downsides, side by side — this is the part worth reading before picking one:
| Native install | Docker, host networking | Per-container sidecar |
|---|---|---|
Lives outside your docker compose world — a separate update mechanism, invisible to Dockhand/Portainer-style container dashboards, a separate thing to remember exists when auditing "what's running here." | network_mode: host drops Docker's network isolation for that one container — it gets full access to the host's network interfaces, a meaningfully bigger grant than a normal app container. Also Linux-only in practice; doesn't behave the same under Docker Desktop on Mac/Windows. | One more container and one more Tailscale identity per app you do this for — more auth keys to mint and eventually rotate, more entries in the admin console to keep straight. The app also now has no independent network at all: if the sidecar container dies, the app becomes unreachable over the tailnet even while it's still running fine — a dependency that doesn't exist in either host-level option. |
Both host-level options are the right default for a typical homelab. Pick native if you'd rather manage it with your OS's own tools; pick the Docker/host-networking route if you'd rather see it alongside your other containers. Reach for the sidecar only when one of the reasons below actually applies — it solves a real, narrower problem, not a "more secure by default" upgrade.
Use a host-level option (either flavour above) when:
- You run a typical homelab where "which host is this on" isn't a meaningful trust boundary — you'd give the same people access to everything on that box anyway.
- You want the least Tailscale-specific config to maintain: one install, forget it.
- Containers stay put. They're not being rescheduled across machines by an orchestrator or a habit of moving things around.
Reach for a sidecar instead when:
- One app needs stricter or different ACLs than its neighbours. Example: a finance/admin tool on the same host as everything else, but only you — not the rest of the household's tailnet devices — should ever reach it. Host-level Tailscale can't express that; the whole host is one reachability unit. A sidecar gives that one app its own identity to tag and restrict independently.
- The app needs a stable tailnet name that survives moving hosts. If you migrate containers between machines with any regularity (a Docker Swarm/Nomad-style setup, or just a habit of reshuffling), a sidecar's identity travels with the container; host-level reachability doesn't — the address changes the moment the app lands somewhere else.
- You want to use Tailscale Funnel or Serve scoped to one app, without the whole host becoming a funnel target.
The compose shape, if you do want it:
services:
app-tailscale:
image: tailscale/tailscale:latest
hostname: app # this is the tailnet name it gets
environment:
- TS_AUTHKEY=your-auth-key # from the Tailscale admin console
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- /path/to/appdata/app-tailscale:/var/lib/tailscale
cap_add: [NET_ADMIN]
devices: [/dev/net/tun]
restart: unless-stopped
app:
image: someproject/app:latest
network_mode: service:app-tailscale # app shares the sidecar's network
restart: unless-stopped
Why this homelab runs host-level, not sidecars, for everything: none of the "reach for a sidecar" reasons apply here — every app on a given host is trusted roughly equally, nothing gets migrated between hosts as a matter of routine, and Funnel/Serve aren't in use. One install per host does the same job as one container per app, for less to configure and fewer devices to manage in the admin console. If your situation matches any of the three bullets above, though, the sidecar is the correct tool, not a workaround — it's not "more secure" in the abstract, it's solving a real, different problem.
Checkpoint
- Host shows up in your Tailscale admin console, with a stable
100.x.y.zaddress - A container's published port is reachable at that address from another tailnet device, and not reachable from the open internet
- MagicDNS gives you a name instead of memorizing the IP
- Nothing you meant to keep private has a Cloudflare Tunnel route
Previous: Part 2 — Cloudflare Tunnel with Docker. Start of series: Part 1 — Cloudflare vs Tailscale.
If this saved you some time, a coffee keeps the lights on and the posts coming.
☕ Buy me a coffee