Run Tailscale in a Docker Compose Stack
The vendor Compose example leaves your app on a public host port. Use a Tailscale sidecar so the container has no published port, only a tailnet name.
Run Tailscale in a Docker Compose stack
Running Tailscale in Docker Compose takes two services. One is the tailscale/tailscale container that joins your tailnet. The other is your application container, which shares the first container's network namespace instead of publishing a port on the host. The result is a service your laptop opens by name, and the public internet cannot reach at all.
A tailnet is the private network Tailscale builds between the devices you sign in. If that word is new, read what Tailscale is and how it connects two machines first. This guide assumes Docker Engine and the Compose v2 plugin are already working, as set up in a first Docker Compose stack on a VPS.
The vendor example, and the port it leaves open
Tailscale's own Compose guide publishes a stack close to this one.
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: tailscale-nginx
environment:
- TS_AUTHKEY=tskey-auth-REPLACE-ME
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./tailscale-state:/var/lib/tailscale
cap_add:
- net_admin
- net_raw
restart: unless-stopped
nginx:
image: nginx:latest
container_name: nginx_server
ports:
- "8080:80"
depends_on:
- tailscale
restart: unless-stoppedEvery line of the tailscale service is right. TS_AUTHKEY authenticates the node. TS_STATE_DIR tells tailscaled where to write its state, and the bind mount keeps that state on disk. The second service is the problem.
Those two containers sit on the default Compose bridge network, each with its own address. That is the ordinary behaviour explained in how Compose networks connect containers by service name. The tailscale container joined the tailnet for itself, and it forwards nothing to the nginx container. So the only route to nginx is port 8080 on the host.
A published port binds 0.0.0.0 unless you write an address in front of it, so on a VPS that port answers on the public IP. The app is on your tailnet in name and on the internet in fact. A host firewall does not save you either, because Docker inserts its own forwarding rules ahead of ufw's chain. That is the trap described in why a ufw deny rule does not close a published Docker port.
One more detail worth naming. The example grants net_admin and net_raw but never maps /dev/net/tun. TS_USERSPACE defaults to true, so the container runs the userspace network stack, and those two capabilities have nothing to do.
The sidecar: one namespace, no published port
Put the application inside the tailscale container's network namespace with network_mode: service:tailscale. Both processes then see the same loopback and the same tailnet address, even though they run in separate containers.
services:
tailscale:
image: tailscale/tailscale:v1.102.3
container_name: ts-nginx
hostname: nginx-demo
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_HOSTNAME=nginx-demo
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./ts-state:/var/lib/tailscale
restart: unless-stopped
nginx:
image: nginx:1.30.4-alpine
network_mode: service:tailscale
depends_on:
- tailscale
restart: unless-stoppedThe key goes in a .env file beside the compose file, not in the YAML, so the file you commit holds no secret. One line: TS_AUTHKEY=tskey-auth-.... Keeping secrets out of a committed compose file covers the rest of that pattern.
Bring it up and check both halves.
docker compose up -d
docker compose exec tailscale tailscale status
docker compose logs --tail 20 tailscaletailscale status should print a line for this node with a 100.x address, followed by the other machines on your tailnet. From a laptop that is also signed in, curl http://nginx-demo/ returns the nginx welcome page. On the VPS itself, sudo ss -lntp | grep 8080 returns nothing, because no port was published.
Why port 80 and not 8080: in userspace mode tailscaled sends an incoming tunnel connection to the same port on localhost. nginx listens on 80 inside the shared namespace, so the tailnet reaches it on 80. Change the port the app listens on and the tailnet port changes with it. This namespace-sharing trick is not specific to Tailscale, and the same questions about reaching the host and the rest of the stack come up in a Gluetun container that owns its neighbours' networking.
Which auth key does the container need?
The key type decides what happens on the second start, so choose it before you deploy. Generate one on the Keys page of the admin console. The dialog shows it once.
- One-off keys authenticate a single device. A stack recreated without its state directory will not come back.
- Reusable keys authenticate any number of devices. This is what a Compose stack usually wants.
- Ephemeral keys mark the node for automatic cleanup. Tailscale removes an ephemeral device 30 to 60 minutes after its last activity.
- Pre-approved keys skip manual device approval, which matters only if device approval is turned on for your tailnet.
- Tagged keys apply an ACL tag such as
tag:containerat authentication time. The device stops belonging to a person, and its key expiry is disabled by default.
That last line is the operational one. A node key expires after 180 days by default, and an expired node drops off the tailnet until a human signs it in again. A tagged key removes that alarm clock, which is why tags exist for servers and containers.
An auth key expiring is a separate event, and people confuse the two. Auth keys last 1 to 90 days, with 90 as the default. A key reaching its expiry date does not disconnect the devices it already authenticated. It only stops adding new ones. For a long-lived service, use a reusable tagged key that is not ephemeral. For a stack you tear down constantly, such as a preview environment, an ephemeral key keeps the admin console clean without any manual deletion.
Why does the container come back as a new machine?
Because tailscaled wrote its state into the container's writable layer, and docker compose down deleted the container.
Node identity lives in that state directory. Persist it and the container keeps its name and its 100.x address across restarts, along with any serve configuration. Lose it and the next start is a first start: the container authenticates again with the same key, and the admin console gains a second machine. Both claim the hostname nginx-demo, so MagicDNS gives the newer one a numbered suffix, and every link you saved points at the dead node.
Two things have to be true. TS_STATE_DIR=/var/lib/tailscale must be set, because it has no default outside Kubernetes. And that path must be mounted, with either the bind mount above or a named volume, a choice covered in bind mounts versus named volumes. Setting one without the other is the common mistake, and it fails quietly: the stack behaves perfectly until the first down.
Verify it instead of assuming it.
docker compose down
ls -l ./ts-state
docker compose up -d
docker compose exec tailscale tailscale status./ts-state should already contain tailscaled.state before the second up, and the node should return with the address it had before. A different address means the mount is not doing its job.
Pin the image, and name the tag you used
tailscale/tailscale:latest follows the newest stable build. A docker compose pull six months from now silently swaps tailscaled for a different version, and the next restart runs code you never chose. The stack above pins v1.102.3, the stable release as of September 2026. Docker Hub also publishes v1.102 for the patch line and an unstable tag, which you do not want on a server.
Upgrade on purpose.
docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale versionEditing the tag and running up -d recreates the container, which is not the same as restarting it. The difference between restart, up and rebuild is worth reading before you debug a version change that did not take effect.
Userspace networking, and what it costs
TS_USERSPACE defaults to true. The container then runs a TCP/IP stack in userspace and never touches /dev/net/tun, which is what lets this work on hosts that will not hand a container the TUN device. Inbound still works, because incoming tunnel connections are forwarded to the same port on localhost. That is the whole reason the sidecar above needs no device and no capabilities.
Outbound is where it costs you. In userspace mode the application cannot simply open a socket to another tailnet node. tailscaled offers a SOCKS5 proxy and an HTTP proxy instead, so you set TS_SOCKS5_SERVER=localhost:1055 on the tailscale service and ALL_PROXY=socks5://localhost:1055 on the app, and the app has to honour it. Anything that ignores proxy environment variables will not reach the tailnet.
The stack itself has limits worth knowing. Only TCP and UDP are carried, so any other IP protocol such as SCTP does not pass. ICMP is limited to ping, which the daemon reconstructs, and that adds a little apparent latency. Connections are terminated at the node and dialled again to the target, so they are not end to end. A userspace node also cannot use an exit node or a subnet route that someone else advertises, although it can advertise them itself.
When you need transparent outbound traffic, switch to kernel networking by adding three things to the tailscale service.
environment:
- TS_USERSPACE=false
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_adminCheck the host can provide it first with test -c /dev/net/tun && echo ok. On KVM the device is there. On container virtualisation that shares the host kernel it may be missing, and userspace is then your only path. Give the container the TUN device if it is meant to be a subnet router advertising a private range or an exit node for your other devices, because those roles are the ones that suffer most in userspace.
Reaching the service: serve, or the plain MagicDNS name
The simple path is the MagicDNS name. From any device on the tailnet, http://nginx-demo/ works, and so does the full name http://nginx-demo.your-tailnet.ts.net/. Plain HTTP here is not cleartext on the wire, because WireGuard encrypts the traffic between the two nodes, and what the coordination server can and cannot see draws the line on where that guarantee stops. There is no certificate, so the browser marks the origin insecure, and any web feature that demands a secure context refuses to run.
The other path is Tailscale Serve, run inside the container.
docker compose exec tailscale tailscale serve --bg localhost:80
docker compose exec tailscale tailscale serve statusThat publishes the app at https://nginx-demo.your-tailnet.ts.net with a certificate Tailscale provisions. Both MagicDNS and HTTPS certificates have to be enabled on the DNS page of the admin console, or there is no name to put on a certificate. --bg writes the configuration into the tailscaled state you persisted, so it returns with the container, and tailscale serve reset removes it. TS_SERVE_CONFIG points at a JSON file if you would rather keep that configuration in the repository than in a shell command. Serve stays inside the tailnet. Funnel is the separate command that puts the same service on the public internet, so read the difference between Serve and Funnel before typing either one.
Failure modes and the messages you will see
Error response from daemon: conflicting options: port publishing and the container type network mode. You left a ports: block on the sidecar service. Only the container that owns the namespace may publish ports, and a tailnet-only app should publish none. Delete the block.
The app container is running and nothing reaches it. You recreated the tailscale service on its own. The namespace it owned was destroyed with it, and the app is attached to something that no longer exists. Recreate the pair together with docker compose up -d --force-recreate.
No node appears in the admin console. Read docker compose logs tailscale. A rejected key is reported there. A one-off key that was already used, and a key past its expiry date, both stop the node before it ever reaches the tailnet.
The node is up, tailscale status looks right, and curl http://nginx-demo/ hangs. The app is not listening where you think. Ask it from inside the shared namespace with docker compose exec nginx wget -qO- http://localhost/. If that fails too, the problem is the app, not Tailscale. If it succeeds, the app is bound to one interface rather than all of them.
The machine disappeared from the console an hour after you stopped the stack. The key was ephemeral. Removal happens 30 to 60 minutes after the last activity, and that is the feature working.
From version 1.78 the image can expose an unauthenticated /healthz endpoint: set TS_ENABLE_HEALTH_CHECK=true, which listens on TS_LOCAL_ADDR_PORT, default [::]:9002. Point a Compose healthcheck at it so a node that fails to authenticate is reported as unhealthy instead of sitting there looking fine. If you would rather not depend on Tailscale's coordination servers at all, the same compose file talks to your own control plane through TS_EXTRA_ARGS=--login-server=https://headscale.example.com, which is the starting point for running Headscale as your own control server.
FAQ
Why can other devices on my tailnet not reach my app container?
Because the tailscale container joined the tailnet for itself only. If the app runs on the default Compose bridge network with its own address, the tailscale node does not forward anything to it, and the only way in is the published host port. Give the app network_mode: service:tailscale so it shares the tailscale container's network namespace, then remove its ports: block. The app is then reachable on the tailnet at the port it listens on.
Do I need /dev/net/tun to run Tailscale in Docker Compose?
Not for inbound access. TS_USERSPACE defaults to true, and in that mode tailscaled runs its own network stack and forwards incoming tunnel connections to the same port on localhost, so a sidecar works with no device and no extra capabilities. You need /dev/net/tun, TS_USERSPACE=false and net_admin when the container must open outbound connections to the tailnet transparently, or act as a subnet router or exit node.
Should I use an ephemeral or a reusable auth key for a Compose stack?
Use a reusable key, tagged and not ephemeral, for anything long-lived. Tagging disables node key expiry, so the container does not drop off the tailnet after 180 days waiting for someone to sign it in. Choose ephemeral only for stacks you destroy often, such as preview environments, because Tailscale removes an ephemeral device 30 to 60 minutes after its last activity and the admin console stays clean.
Why does my container show up as a new machine every time it restarts?
The state directory is not being persisted, so tailscaled starts with no identity and authenticates as a brand new node. Set TS_STATE_DIR=/var/lib/tailscale, which has no default outside Kubernetes, and mount that path to a bind mount or named volume. Setting one without the other looks fine until the first docker compose down. Confirm tailscaled.state exists in the mounted directory while the stack is down.