Everything not listed in this document should behave the same as upstream Tailscale. If a feature, setting, or behavior is not mentioned here, the upstream documentation is accurate and fully applicable — see the Documentation section of
instructions.mdfor links.
Tailscale is a WireGuard-based mesh VPN: your devices join a private network and reach each other wherever they are. This package joins your server to your tailnet and — through a StartOS plugin — lets you publish any other service's interface onto it from that service's own URL list.
- Upstream repo: https://github.com/tailscale/tailscale
- Wrapper repo: https://github.com/Start9-Community/tailscale-startos
- Image and Container Runtime
- Volume and Data Layout
- File Models
- Dependencies
- Network Access and Interfaces
- Installation and First-Run Flow
- Actions
- Tasks
- Health Checks
- Backups and Restore
- Limitations and Differences
- Quick Reference for AI Consumers
One image, built here, running a variable number of daemons.
| Property | Value |
|---|---|
| Image | Built from this repo's Dockerfile |
| Architectures | x86_64, aarch64 |
| Command | tailscaled in userspace networking mode |
| Subcontainer | Purpose |
|---|---|
tailscale-sub |
Every daemon and oneshot — the one to attach to |
The fixed daemons are tailscaled and Tailscale's own web client. Beyond those, one forwarder daemon and one apply oneshot are added per served route, so the daemon set is rebuilt whenever the set of served interfaces changes.
tailscaled runs in userspace networking mode, without a TUN device — that is what lets it work inside a container without host network privileges. The trade is that it is a client and a serving node, not a subnet router or exit node.
The device name is set before tailscaled registers. Left alone it would adopt the container's random hostname and the node — and every URL published from it — would appear in your tailnet under a meaningless name. Setting it only changes the default, so renaming the device in Tailscale's console still wins.
One volume.
| Volume | Mount Point | Purpose |
|---|---|---|
main |
/var/lib/tailscale |
Node identity and serve config |
| Path | Written by | Holds |
|---|---|---|
tailscaled.state |
tailscaled | The node's identity on your tailnet |
tailscaled.sock |
tailscaled | The control socket |
ts-status.json |
A oneshot | The node's status, for the host side |
| serve config | The actions | Which interfaces are published, and how |
The control socket lives on the volume deliberately. The daemon, the web client, and the serve-apply steps all run as separate processes and all need to talk to tailscaled — putting the socket on shared storage is what lets them.
tailscaled.state is the node's identity. Restoring it elsewhere reproduces the same tailnet device.
Two models, and one of them exists to cross a boundary.
| File | Format | Modelled | Written by |
|---|---|---|---|
| serve config | JSON | Yes — FileHelper.raw |
The actions |
ts-status.json |
JSON | Yes — read only | A oneshot, inside the container |
The serve config is the list of published routes: which package and interface, which mode, which ports. It is read reactively, which is what rebuilds the forwarders and re-applies the serves when a route is added or removed.
The status file exists because the package's own code cannot reach tailscaled. The socket is inside the container; the code that exports URLs and drives the actions runs outside it. So a oneshot waits for the node to reach a running state and writes tailscale status to the volume, where the host side reads the MagicDNS name from it.
Two details of that are load-bearing:
- It is written even with no routes configured, so the name is available immediately after sign-in rather than only after the first serve is added.
- It is written in place, not through a temporary file and a rename. The watch is bound to that file's inode, and an atomic replace would swap the inode and the update would be missed.
None — and it is the other direction that matters. Every other service is a potential target, reached over the internal bridge when a route is applied.
Tailscale needs internet to reach its coordination servers and to build connections to your other devices.
One StartOS interface, and it is not where the interesting traffic goes.
| Interface | Id | Type | Port | Description |
|---|---|---|---|---|
| Admin | web |
ui | 8240 | Tailscale's own web client |
This interface is the sign-in and management surface, not the way served services are reached. Those are reached over the tailnet, at the node's MagicDNS name, and never through a StartOS address.
Serving works by forwarding, because Tailscale only serves what is on localhost. For each route the package runs a small forwarder from a local port to the target service's bridge address, and points tailscale serve at that local port. It is why a route needs a daemon of its own.
Four modes are supported, and one of them leaves your tailnet:
| Mode | Reachable by |
|---|---|
https |
Your tailnet, with Tailscale's TLS |
http |
Your tailnet, plaintext |
tcp |
Your tailnet, raw TCP — for non-web services |
funnel |
The public internet |
Funnel publishes a service to anyone, not just your devices. It is a deliberate choice offered in the same form as the others, and it is limited to the ports Tailscale allows for it.
Install registers the plugin and nothing else. There is no task and no credential.
Sign-in happens in Tailscale's own web interface, not through a StartOS action: open the admin interface, follow the link to authenticate the node against your tailnet, and it joins.
Once the node is running, the package records its status and its MagicDNS name becomes available. From then on, every other service's URL list gains a "Serve On Tailscale" button — that is the plugin at work, and it is how routes are added.
Adding a route restarts this service, since the forwarder set is rebuilt.
Two actions, both hidden.
They do not appear in this service's Actions tab, because neither is meant to be run from here: they are invoked from the StartOS URL table of whichever service you are publishing.
Adds a route: publishes one interface of one service onto the tailnet, in the chosen mode and on the chosen port.
- Invoked from the target service's URL list, via the button the plugin adds.
- What it changes: the serve config, and through it this service's forwarder set.
- Cost: Tailscale restarts and re-applies every route.
- Funnel mode publishes to the public internet, and is restricted to the ports Tailscale permits.
Removes a route.
- Invoked from the exported row in the same URL table.
- What it changes: the serve config; the forwarder and its serve rule go away on the restart.
Both are hidden rather than absent, so the platform can drive them while a user never has to find them here.
None. This package raises no tasks, so the service is never held on a prompt and its ordinary controls are always available.
Two named checks, plus one hidden check per route.
| Check | Displayed as | Method |
|---|---|---|
tailscaled |
"Tailscale Daemon" | The daemon's own reported state |
web |
"Tailscale Admin Interface" | Port 8240 is listening |
fwd-<route> |
— internal | That route's forwarder is listening |
The daemon check reports "waiting for login" as a success, not a failure, and that is right: a node that has never been signed in is working correctly, it just has not been told who it belongs to yet. Only an unreachable or unparseable daemon fails.
None of the checks says whether a served service is actually reachable from another device. That depends on the target service being up and on Tailscale's own connectivity, and it is visible from the device you are connecting with.
The main volume is copied wholesale — sdk.Backups.ofVolumes('main'). That is the node's identity, the serve configuration, and the recorded status.
The backup is the tailnet identity. Restoring it reproduces the same device, with the same name and the same authorization — which is what makes a restore seamless and what makes the backup sensitive.
Do not run a restored copy alongside the original. Two nodes presenting one identity to a tailnet is not a supported configuration.
A restored instance comes back signed in, with its routes intact, and re-resolves each target's bridge address on the new server.
- Userspace networking only. No TUN device, so this node is not a subnet router or an exit node.
- Served services are reached over the tailnet, never through a StartOS address.
- Funnel mode publishes to the public internet, on Tailscale's permitted ports only.
- Every route costs a forwarder daemon, and adding or removing one restarts the service and re-applies all routes.
- A route's target must expose an HTTP or HTTPS endpoint for the web modes; anything else has to use raw TCP.
- Sign-in is out of band, in Tailscale's own interface — there is no StartOS action for it.
- The backup reproduces the device identity, so never restore two copies.
- Tailscale's coordination servers are a third party your devices depend on to find each other.
package_id: tailscale
image: built from ./Dockerfile
architectures:
- x86_64
- aarch64
subcontainers:
- tailscale-sub # all daemons and oneshots share it, and the tailscaled socket
volumes:
main: /var/lib/tailscale # tailscaled.state + .sock, ts-status.json, serve config
file_models:
- serve config # the published routes; read reactively, rebuilds the daemon set
- ts-status.json # written in-container, read on the host for the MagicDNS name
startos_managed_env_vars: []
dependencies: [] # but every other package is a potential serve target
interfaces:
web: { type: ui, port: 8240 } # Tailscale's own web client — sign-in and management
actions:
- add-serve-from-url # hidden; invoked from another service's URL table
- remove-serve-from-url # hidden; invoked from the exported row
tasks: []
health_checks:
- tailscaled # "waiting for login" reports success, not failure
- web
- fwd-<route> # internal (display: null)