
Administering an Incus OS cluster means reaching its nodes over the network. Rather than exposing the API or SSH (absent here) on a public network, you attach each node to a Tailscale tailnet: an encrypted mesh network where machines reach each other through a stable private 100.x address, wherever they sit. This guide shows how to enable the Incus OS Tailscale service entirely through the API, with no shell, verify the mesh is up, and advertise routes towards the instances. Intended audience: administrators of an Incus OS cluster.
What you will learn
- Understand what the Tailscale service of Incus OS connects, and what it does not.
- Generate an authentication key suited to a fleet of nodes.
- Enable and configure the service through the API, without exposing the key.
- Check the state of the mesh (
100.xaddresses, peers online). - Advertise routes, or point at a Headscale control server.
Prerequisites
- A running Incus OS cluster. See Incus OS without a shell and Incus cluster.
- A Tailscale account, or a Headscale control server publicly reachable over HTTPS, covered at the end of this guide.
- An Incus client whose certificate is trusted, with a remote for each node.
- Outbound internet access from the nodes, to reach the Tailscale control plane.
As with A/B updates, all configuration goes through the REST API prefixed with /os, queried with incus query. The Tailscale service is configured per node: every command targets one member through its remote.
The Incus OS Tailscale service
Unlike the Ceph client, the Tailscale client is already embedded in the Incus OS image: there is no add-on to install. The service is simply disabled by default. You enable it by writing its configuration, and Incus OS starts the tailscaled daemon and performs the attachment to the network.
One essential point before starting: the service connects the node (the Incus OS host) to the tailnet, not the instances it hosts. A container or a virtual machine does not get a Tailscale address just because its host is connected. To make instances reachable, you go through advertised routes (a subnet router), covered further down.
Generating an authentication key
Attaching a node non-interactively requires an auth key. From the Tailscale console, in the key settings, generate a key with the options that suit a fleet:
- Reusable: a single key serves every node of the cluster.
- Ephemeral: a disconnected node is removed automatically from the tailnet, which avoids ghost entries after a test or a decommission.
- Short expiry: limit the window during which the key is valid.
The key looks like tskey-auth-.... It is a secret: it grants the right to enrol machines into your network. Do not store it in the clear, and do not leave it sitting in a shell history.
Enabling the service on one node
The configuration is written with a PUT on services/tailscale. To avoid exposing the key, read it with masked input, write a temporary file with tight permissions, then delete it. Start with a single node so you validate before generalising:
umask 077read -rsp 'Tailscale auth key: ' TSKEY; echocat > /tmp/ts.json <<JSON{"config":{"enabled":true,"auth_key":"$TSKEY","accept_routes":false,"accept_dns":false,"login_server":""}}JSONunset TSKEY
incus query -X PUT "node1:/os/1.0/services/tailscale" -d "$(cat /tmp/ts.json)"An empty login_server means the default Tailscale control plane, served over HTTPS with a valid public certificate. In response, Incus OS starts tailscaled and runs the equivalent of a tailscale up with the supplied key. The accept_routes field at false means the node does not import routes advertised by other peers; accept_dns at false leaves the node's local DNS resolution untouched.
Checking the connection
Attachment takes a few seconds. Then query the service state. Careful: the read also returns config.auth_key in the clear, so filter on the state part before sharing any output.
incus query "node1:/os/1.0/services/tailscale"The state section should show an active backend and a Tailscale address:
{ "state": { "backend_state": "Running", "version": "1.100.0", "tailnet_name": "your-account", "self": { "host_name": "node1", "dns_name": "node1.your-tailnet.ts.net.", "tailscale_ips": ["100.113.80.118", "fd7a:115c:a1e0::fc35:5077"] }, "health": [] }}A backend_state of Running and a 100.x address confirm the node joined the tailnet. The self.dns_name field gives the MagicDNS name the node can now be reached by from your other Tailscale machines.
Extending to the whole cluster
Once the first node checks out, apply the same configuration to the other members, then delete the file holding the key:
for n in node2 node3; do incus query -X PUT "$n:/os/1.0/services/tailscale" -d "$(cat /tmp/ts.json)"doneshred -u /tmp/ts.json 2>/dev/null || rm -f /tmp/ts.jsonEach node gets its own 100.x address and sees the others as peers. You can list the peers a node sees under state.peer: the other cluster members should appear there with online set to true. The mesh is then complete: nodes talk to each other through the encrypted tunnel, whatever their network location.
Driving the service from Operations Center
On a fleet managed by Operations Center, you do not edit each node one by one: the console proxies the service configuration of every server. You read or change a member's Tailscale service through its CLI:
operations-center provisioning server os service show IncusOS01:tailscaleoperations-center provisioning server os service edit IncusOS01:tailscaleThe edit takes the same configuration object as the direct API (enabled, auth_key, advertised_routes and so on) and applies it to the target node. That is the centralised channel to prefer once a fleet is enrolled, with incus query still useful for an isolated node or an unmanaged cluster. This part of the API is still marked experimental in current versions.
Advertising routes towards the instances
Since the service connects the host and not the instances, making a container or a virtual machine reachable from the tailnet means advertising its subnet. You declare the routes the node offers with advertised_routes, and allow other nodes to use them with accept_routes:
incus query -X PUT "node1:/os/1.0/services/tailscale" \ -d '{"config":{"enabled":true,"auth_key":"tskey-auth-...","advertised_routes":["10.167.207.0/24"],"accept_routes":true}}'The instance subnet then becomes routable for tailnet members that accept routes, once the route has been approved on the Tailscale administration side. The service also exposes serve_enabled, serve_port and serve_service to publish the Incus API through Tailscale Serve, an option worth reserving for cases where direct HTTPS access to the interface is wanted.
Using Headscale instead
The login_server field accepts the URL of a Headscale control server, the free, self-hosted implementation of the Tailscale control plane. The configuration is identical: you just add the URL and a key generated on the Headscale side (headscale preauthkeys create).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
backend_state stays empty or NeedsLogin | Key invalid, expired or already consumed | Generate a reusable, unexpired key and reapply the PUT |
The node gets no 100.x address | No outbound internet access to the control plane | Check the node's default route and DNS |
Failure with a Headscale login_server | URL over HTTP, or an untrusted certificate | Expose Headscale over HTTPS with a trusted certificate |
| Instances stay unreachable | The service connects the host, not the instances | Declare advertised_routes and approve the route on the Tailscale side |
Key points
- The Tailscale client is embedded in Incus OS: no add-on, you just enable the service.
- All configuration goes through
PUT /os/1.0/services/tailscale, per node. - The service connects the node, not the instances: for those, use
advertised_routes. - Use a reusable and ephemeral key for a fleet, and delete the file holding it.
- Reading the state returns the key in the clear: filter on
statebefore sharing output. - Headscale works through
login_server, but demands a publicly valid HTTPS certificate.