What you get
A single Go binary that is its own device on your tailnet.
Not a program behind a tailnet. Not a program in a container next to a tailscaled sidecar. The process itself has a Tailscale IP, a MagicDNS name, and an entry in your admin console. Kill the process and the device goes away. Start it on a different box and the same device moves with it.
The package doc states the goal plainly: tsnet “embeds a Tailscale node directly into a Go program, allowing it to join a tailnet and accept or dial connections without running a separate tailscaled daemon or requiring any system-level configuration” [tsnet-src].
That last clause is the whole point. No tailscaled unit file. No /dev/net/tun. No NET_ADMIN capability. No root. No host networking mode on the container. Nothing on the host is modified, because nothing on the host is involved.
What you actually hold in your hand after twenty lines of code:
- A
net.Listenerthat only exists on the tailnet [tsnet-pkg]. You hand it tohttp.Servelike any other listener, and the resulting service is unreachable from the host’s own network stack. - An
*http.Clientthat dials out over the tailnet [tsnet-pkg], so your service can call other tailnet services by name. - A
WhoIscall that tells you the login name, device, tags, and policy grants of whoever just connected [local-pkg] [apitype]. You get authenticated callers without writing a single line of login code.
That third one is the part most people never reach, and it is the reason this recipe is worth your afternoon.
How it works
Tailscale normally runs as a daemon that owns a tunnel device and rewrites your host’s routing table. tsnet does neither. It runs the same node logic inside your process against a userspace TCP/IP stack [tsnet-kb] [tsnet-src], so packets never touch the kernel’s network configuration.
The reverse direction is just as clean. Server.HTTPClient returns an *http.Client whose transport dials through the tsnet node [tsnet-pkg] [tsnet-src], so client.Get("http://node-b/metrics") resolves and routes on the tailnet, not on the host.
Build it
1. Start a module and pull the dependency. The module path is tailscale.com [tsnet-pkg].
go mod init example.com/inventory
go get tailscale.com
2. Mint an auth key. In the admin console, create a key that is tagged, non reusable, and pre-approved if your tailnet has device approval on [authkeys]. Tagging matters: tagged devices get their identity from the tag, and grants target tags rather than a person.
3. Pick a state directory that survives restarts. Dir “specifies the name of the directory to use for state” and defaults to a location under os.UserConfigDir based on the binary name [tsnet-pkg]. In a container, that default is inside the writable layer, which is the wrong place. Point it at a mounted volume.
4. Write the program.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)
// capInventory is an app capability declared in your tailnet policy file.
// A grant attaches values under this name to a (source, destination) pair.
const capInventory tailcfg.PeerCapability = "example.com/cap/inventory"
// grantValue is one JSON object from the "app" section of that grant.
type grantValue struct {
Actions []string `json:"actions"`
}
func main() {
stateDir := os.Getenv("TSNET_STATE_DIR")
if stateDir == "" {
stateDir = "/var/lib/inventory/tsnet"
}
srv := &tsnet.Server{
// Hostname is what control sees. MagicDNS name becomes
// inventory.your-tailnet.ts.net.
Hostname: "inventory",
// Dir holds this node's identity. Lose it and you re-register.
Dir: stateDir,
// Read the key from the environment. Never compile it in.
// tsnet also honors TS_AUTHKEY; this is the explicit form.
AuthKey: os.Getenv("TS_AUTHKEY"),
// UserLogf carries login URLs and status meant for a human.
// Logf is the verbose backend log; leaving it nil discards it.
UserLogf: log.Printf,
}
defer srv.Close()
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
// Up blocks until the node is actually running, and hands back status.
status, err := srv.Up(ctx)
if err != nil {
log.Fatalf("tsnet did not come up: %v", err)
}
log.Printf("node up: state=%s addrs=%v",
status.BackendState, status.TailscaleIPs)
// LocalClient talks to the LocalAPI of this in-process node.
lc, err := srv.LocalClient()
if err != nil {
log.Fatalf("local client: %v", err)
}
// This listener exists only on the tailnet. Nothing binds on the host.
ln, err := srv.Listen("tcp", ":80")
if err != nil {
log.Fatalf("listen: %v", err)
}
defer ln.Close()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
http.Error(w, "cannot identify caller",
http.StatusInternalServerError)
return
}
actions, err := allowedActions(who)
if err != nil {
log.Printf("bad capability value from %s: %v", r.RemoteAddr, err)
http.Error(w, "malformed grant", http.StatusInternalServerError)
return
}
if len(actions) == 0 {
http.Error(w, "no grant covers this caller",
http.StatusForbidden)
return
}
fmt.Fprintf(w, "caller: %s\n", who.UserProfile.LoginName)
fmt.Fprintf(w, "node: %s\n", who.Node.ComputedName)
fmt.Fprintf(w, "tagged: %v %v\n", who.Node.IsTagged(), who.Node.Tags)
fmt.Fprintf(w, "actions: %v\n", actions)
})
httpSrv := &http.Server{Handler: mux}
go func() {
<-ctx.Done()
httpSrv.Close()
}()
if err := httpSrv.Serve(ln); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serve: %v", err)
}
}
// allowedActions reads the capability values your policy file attached to
// this caller. No cookie, no bearer token, no local user table.
func allowedActions(who *apitype.WhoIsResponse) ([]string, error) {
vals, err := tailcfg.UnmarshalCapJSON[grantValue](who.CapMap, capInventory)
if err != nil {
return nil, err
}
var out []string
for _, v := range vals {
out = append(out, v.Actions...)
}
return out, nil
}
5. Add the grant to your tailnet policy file. This is what makes CapMap non empty. The app field maps a capability name to an array of JSON values [grants-app].
{
"src": ["group:ops"],
"dst": ["tag:inventory"],
"ip": ["tcp:80"],
"app": {
"example.com/cap/inventory": [
{"actions": ["read", "restart"]}
]
}
}
6. Run it.
TS_AUTHKEY=tskey-auth-... TSNET_STATE_DIR=/var/lib/inventory/tsnet ./inventory
Verify it
Watch the log line from Up. BackendState should read Running and TailscaleIPs should list the addresses control assigned [ipnstate]. Both come straight off the *ipnstate.Status that Up returns [tsnet-pkg].
Then, from node-b:
curl http://inventory
You should get back your own login name, the calling device’s name, its tag state, and the actions your grant allows. If the caller has no matching grant, you get a 403 and the handler never sees a request body.
Now the confirmation that this is genuinely not on the host. On cloud-1, run ss -ltn and look for port 80. It is not there. Run ip link and look for a tailscale0 interface. It is not there either. Run curl http://localhost on cloud-1 itself and it fails. The service exists on exactly one network, and that network is your tailnet.
Finally, check the admin console. A device named inventory is listed. Stop the process, start it again, and confirm it is still the same device with the same IP. If a second device appeared, your state directory is not persisting, which is the next section.
Gotchas
One Dir per node, always. The docs are explicit: “If you want to use multiple tsnet services in the same binary, you will need to make sure that Dir is set uniquely for each service” [tsnet-pkg]. Two servers sharing a directory will fight over the same state file.
Listen with no IP only matches traffic addressed to this node. The doc says listeners without an IP “will match for traffic for the local node (that is, a destination address of the IPv4 or IPv6 address of this node) only. To listen for traffic on other addresses such as those routed inbound via subnet routes, explicitly specify the listening address or use RegisterFallbackTCPHandler” [tsnet-pkg]. If your tsnet service is behind a subnet router, this is the bug you will spend an hour on.
Ephemeral versus reusable, and which to pick. Ephemeral keys make the device clean itself up after it goes offline [authkeys], which is exactly right for a short lived job: a batch worker, a CI runner, a Lambda-style function. For a long running service you want the opposite: a non reusable, tagged key plus a persistent Dir, so the node registers once and keeps the same identity across restarts. Reusable keys are a convenience with teeth, and the docs say so directly: “Be very careful with reusable keys! These can be very dangerous if stolen” [authkeys]. Set Ephemeral: true on the tsnet.Server when you want ephemeral behavior [tsnet-pkg]. One useful consequence of tagging: for tagged devices, key expiry is disabled by default [authkeys], so your service does not silently drop off the tailnet in ninety days.
Close has ordering rules. “It must not be called before or concurrently with Start” [tsnet-pkg]. A defer srv.Close() placed before your first Listen or Up call is fine, because those start the server. A shutdown path racing a startup path is not.
The LocalAPI client is not a frozen API. The local package says its methods “vary in maturity” and that anything without an explicit stability note “should be assumed to be unstable” [local-pkg]. WhoIs is widely used and stable in practice, but pin your tailscale.com version and read release notes before upgrading.
Where to take it next
Serve HTTPS inside the tailnet. ListenTLS “returns a TLS listener wrapping the tsnet listener” [tsnet-pkg]. Or wrap Listen yourself with tls.NewListener and GetCertificate: lc.GetCertificate, which is exactly what tshello does on port 443 [tshello] [local-pkg].
Go public with Funnel. ListenFunnel(network, addr string, opts ...FunnelOption) “announces on the public internet using Tailscale Funnel” and by default also listens on your tailnet; pass FunnelOnly() to restrict it to internet traffic [tsnet-pkg]. The official example is four lines around s.ListenFunnel("tcp", ":443") plus s.CertDomains()[0] to print the URL [tsnet-funnel-example]. Requirements are real and worth knowing before you try: a funnel node attribute in your policy file, HTTPS certificates enabled, MagicDNS on, only ports 443, 8443, and 10000, and only names in your own tailnet-name.ts.net domain [funnel]. Combine this with WhoIs carefully, because Funnel traffic is not tailnet traffic and will not carry a tailnet identity.
Dial outward. HTTPClient “returns an HTTP client that is configured to connect over Tailscale” and is meant for “your tsnet services connect to other devices on your tailnet” [tsnet-pkg]. A mesh of small tsnet binaries calling each other by MagicDNS name is a genuinely pleasant architecture.
Advertise a Tailscale Service. ListenService(name string, mode ServiceMode) advertises the node as hosting a named Service, with ServiceModeTCP and ServiceModeHTTP controlling port, TLS termination, and forwarded app capabilities [tsnet-pkg]. Note the constraint the package encodes as ErrUntaggedServiceHost: “service hosts must be tagged nodes” [tsnet-pkg], and approval still comes from an admin or an auto-approval rule.
Debug what you cannot see. Because there is no host interface, tcpdump on cloud-1 tells you nothing about tailnet traffic. CapturePcap(ctx, pcapFile) writes what netstack sees to a pcap file, which the docs describe as “useful during debugging, probably not useful for production” [tsnet-pkg]. Loopback() is the other escape hatch: it starts a SOCKS5 proxy onto the tailnet plus the LocalAPI on a loopback address, both credential protected [tsnet-pkg].
Understand what you gave up. No host route means nothing else on cloud-1 can use this tailnet connection, so if you need the whole machine on the tailnet you still want tailscaled. One node per process means N services equals N devices in your console and N entries against your device count. Userspace networking costs some throughput compared to a kernel tunnel device. And key management moves into your deployment system, where a leaked environment variable is now a tailnet device rather than an application secret. Those are real prices. For an internal service that should be reachable by exactly the right people and invisible to everyone else, they are cheap ones.