Homelab Haven

Tailscale with Docker: Reach Your Apps Without Any Public Exposure

June 29, 20269 min read

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.z address, 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/tailscale as 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 installDocker, host networkingPer-container sidecar
Networking modelHost-levelHost-level (identical result)Per-app identity
Setup costOne install, OS-managedOne container, network_mode: hostOne extra container, per app
Trust boundaryWhole host shares reachabilityWhole host shares reachabilityEach app isolated by default
ACL granularityPer host onlyPer host onlyPer app
Follows the app across hostsNoNoYes
Devices in your admin consoleOne per hostOne per hostOne per app

Downsides, side by side — this is the part worth reading before picking one:

Native installDocker, host networkingPer-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.z address
  • 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