TSLinkTSLink Docs

Command Reference

Complete reference for all TSLink commands

View as Markdown

Overview

TSLink provides a straightforward set of commands for managing services on your Tailscale network. All commands follow the pattern tslink <command> [arguments] [flags].

Authentication

Store a credential through a guided entry menu. Ordinary login is not a browser OAuth callback; its standalone page-opening helpers are separate.

bash
tslink login

Non-interactive mode (for CI/CD and automation):

bash
# Via stdin (keeps secrets out of argv and shell history)
printf %s "$TSLINK_API_KEY" | tslink login --api-key-stdin
printf %s "$TSLINK_CLIENT_SECRET" | tslink login --client-secret-stdin

# Via environment variables pre-injected by a secret manager
tslink login

Choose exactly one explicit credential source (one stdin flag or one compatibility argv flag); multiple explicit sources are a usage error. Without an explicit source, environment variables are considered before the interactive prompt. In --json mode, interactive login is disabled — a credential must be provided via stdin, a secret-manager injected environment variable, or a compatibility argv flag. Prefer stdin for automation so secrets do not enter shell history or process listings. If you use TSLINK_API_KEY or TSLINK_CLIENT_SECRET, inject it before process start with a secret manager rather than writing an inline assignment in the command.

Flags:

FlagDescription
--api-key-stdinRead an API access token from stdin
--client-secret-stdinRead an OAuth client secret from stdin
--manage-aclOpt in to remote ACL tag-owner mutation; default login does not rewrite shared ACL policy
--api-key <token>Compatibility path for API access tokens; avoid in automation because argv can leak
--client-secret <secret>Compatibility path for OAuth client secrets; avoid in automation because argv can leak
--expires-in <duration>Record the API access token expiry as a duration from now (90d, 30d, or a Go duration); api-key only
--expires-at <timestamp>Record the API access token expiry as an RFC3339 timestamp; api-key only, and mutually exclusive with --expires-in
--retire-otherDelete the other credential slot after this one is verified and committed; by default the api-key and client-secret slots coexist
--open-keys-pageStandalone helper: open the Tailscale API keys page and exit 0 without storing a credential
--open-oauth-pageStandalone helper: open the Tailscale OAuth page and exit 0 without storing a credential

TSLink accepts two types of keys:

  • API access token (tskey-api-*) — verified on input, auth keys derived automatically on tslink serve.
  • OAuth client secret (tskey-client-*) — does not expire, but current Tailscale REST tag/device automation is narrower than API-token mode.

Credentials prefer the system keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager). Plaintext 0600 file fallback is allowed only on macOS/Linux after TSLink proves an old keychain value is absent or has been deleted. An unreachable or uncertain keychain fails closed; restore access and retry. Windows does not allow credential file fallback. Headless operation alone does not guarantee fallback.

Generate API tokens at Admin → Keys. Generate OAuth secrets at Admin → OAuth (click "+ credential" → "OAuth client" → the scopes required by your intended API operations → copy the client secret).

An API access token expires, and TSLink records when. Give it --expires-in or --expires-at and it stores what you state; give it neither and it assumes the 90-day maximum and records expires_at_source=assumed_max, so a later tslink doctor can tell an assumed deadline from a stated one instead of treating a guess as a fact.

--open-keys-page and --open-oauth-page are bootstrap helpers, not login modes. Each opens a browser only when stdin is an interactive terminal and CI is unset; otherwise it prints the URL and the bootstrap steps. Either way it exits 0 having stored nothing, which is what makes it safe to run from a script that is figuring out where to get a credential.

Clear your Tailscale authentication state and remove stored credentials (API key and/or OAuth client secret). The gateway must be stopped before logging out.

bash
tslink logout

This removes all credentials from the system keychain (and any legacy files), plus the node state from ~/.config/tslink/. Your service registry (registry.json) is preserved.

Flags:

FlagDescription
--kind <api-key|client-secret>Remove only that one credential slot and its metadata; node state and the other slot are kept

Use --kind to retire one credential while the other keeps working, for example after moving automation from an OAuth client secret to an API access token.

Service Management

Register or replace a named service. Exactly one of --proxy, --dir, or --tcp is required. Default add ensures the background gateway is running and may return an enrollment handoff; use url --wait after authorization. Do not start another serve process after a default add.

Service type flags:

FlagDescriptionExample
--proxy <target>Reverse proxy to a local web service--proxy localhost:3000
--dir <path>Serve a file directory over HTTPS--dir ~/Documents/shared
--tcp <host:port>Raw TCP proxy (databases, custom protocols)--tcp localhost:5432

Behavior flags:

FlagDescriptionRequires
--ephemeralRequest an ephemeral node; control-plane cleanup follows inactivity, not immediate disconnectAny type
--tags <tags>Comma-separated ACL tags (e.g., tag:web,tag:internal). Omit to use the default tag (tag:tsmain).Any type
--allow <identities>Comma-separated allowed identities for proxy/file HTTP services. Rejected with --tcp and --funnel; raw TCP is protected by Tailscale ACLs/tags and the target service's own auth.--proxy or --dir
--funnelRequest public exposure via Tailscale Funnel--proxy only
--publicAcknowledge that --funnel exposes the proxy service to the public internet--funnel
--funnel-ttl <ttl>Shared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused.--funnel
--no-auto-provisionDisable automatic Funnel policy provisioning--funnel
--control-url <url>Per-service control server URL (e.g., Headscale)Any type
--no-daemon-installSave configuration only; do not install or start the background serviceAny type
--dry-runValidate and print the service without writing registry or starting a gateway (default false)Any type
--wait <duration>Wait for an exact runtime URL or enrollment URL (default 30s; --wait=0 registers without waiting)Any type

Additional flags:

FlagDescription
--ack-unlimited-request-bodyAcknowledge removal of the HTTP request body size limit
--force-unsafe-publicDANGER: override a never-public recipe policy (requires --recipe, --funnel and --public)
--health-bodyExpected body substring within first 64 KiB (proxy only; avoid secrets in argv)
--health-intervalBackend probe interval, 10s to 1d
--health-pathHTTP business probe path (default /; proxy only)
--health-status-maxHighest expected HTTP probe status (proxy only)
--health-status-minLowest expected HTTP probe status (proxy only)
--health-timeoutBackend probe timeout, 100ms to 30s
--idle-timeoutHTTP keep-alive idle timeout (default 60s)
--max-request-bodyMaximum HTTP upload size (default 32MiB); unlimited requires --ack-unlimited-request-body
--preserve-hostForward this node's canonical external Host (proxy only; recipes choose their default)
--recipeUse an app recipe; preview by default, apply with --yes
--request-header-timeoutHTTP header read timeout (default 10s)
--request-read-timeoutMaximum time without body read progress, not total upload time (default 30s)
--yesApply a reviewed recipe plan (requires --recipe)
--requestableLet human tailnet members ask for this app from the portal; discloses its name; default off

Examples:

bash
# Expose a web application
tslink add webapp --proxy localhost:3000

# Expose an API server with ACL tags
tslink add api --proxy 127.0.0.1:8080 --tags tag:api,tag:prod

# Expose a directory for file sharing
tslink add docs --dir ~/Documents/shared

# Expose a PostgreSQL database via TCP
tslink add mydb --tcp localhost:5432

# Request an ephemeral node; local registration remains until removed
tslink add devserver --proxy :8080 --ephemeral

# Public access via Tailscale Funnel
tslink add public-site --proxy localhost:3000 --funnel --public

# Restrict access to specific users
tslink add internal --proxy localhost:9090 --allow user@example.com,tag:admin

# Use a specific control server for this service
tslink add headscale-app --proxy localhost:3000 --control-url https://headscale.example.com

Remove a registered service by name. Local registry removal succeeds immediately. Local tsnet state is deleted only once remote identities are proven gone; with a running daemon, reconciliation owns that state deletion. Remote tailnet cleanup is attempted only when TSLink has exact ownership proof for a matching device; otherwise matching candidates are reported as protected and cleanup is skipped.

bash
tslink remove webapp

remove is idempotent. Removing a name that was never registered is a successful call that removed nothing, so read removed rather than ok to answer "is the service gone":

json
{"type":"tslink.result","ok":true,"schema_version":1,"command":"remove","code":0,"data":{"name":"docs-example-nonexistent","removed":false,"device_cleaned":false,"device_cleanup_skipped":false}}

If the gateway is running, the service will be removed via hot-reload without restarting.

Flags:

FlagDescription
--strictReturn not_found (exit 5) when the service is absent, instead of the idempotent success above

Idempotence is the right default for a cleanup script that must not fail on a second run. --strict is for the opposite case: a caller that wants "this name was actually registered" to be an enforced precondition.

Display all registered services in a table format, whether the gateway is running or not.

bash
tslink list

Output columns:

ColumnDescription
NAMEService hostname on your tailnet
TYPEService type (proxy, file, or tcp)
BACKENDLocal target (host:port for proxy/tcp, path for file)
ENDPOINTExpected typed tailnet endpoint
EXPOSURETailnet, allow-list, or Funnel exposure

Flags:

FlagDescription
--name <name>Return only the exact service name
--type <type>Filter by service type: proxy, file, or tcp
--fields <list>Comma-separated slim fields for the --json view
--verboseReturn the complete owner-only diagnostic view
--tailnetRead-only: list every TSLink-tagged device in the whole tailnet instead of this machine's registered services

The default list reads this machine's ~/.config/tslink/registry.json. --tailnet asks the Tailscale API instead and reports every TSLink-tagged device in the tailnet: services registered here, services registered on other machines, and orphan nodes. It answers the cross-machine question the per-machine registry cannot.

bash
tslink list --tailnet
tslink list --tailnet --json

Each row says which side of the machine boundary it came from:

originMeaning
local_registryThis machine's registry.json holds a service with exactly this hostname
local_name_variantThe hostname is a <service>-N tsnet collision variant of a service registered here, the usual shape of an orphan this machine left behind
unregisteredThis machine's registry knows nothing about the hostname: another machine's service, or an orphan

The human view ends with N of M TSLink-owned tailnet devices are not registered on this machine., and the JSON payload carries registered_count and unregistered_count alongside count. Every result also carries a constant cleanup_authority field, because the view exposes a real limit: tslink cleanup deletes only devices whose exact NodeID is recorded in this machine's local node-ownership.json, so a device this machine's registry does not name must be cleaned up from the machine that created it. --tailnet never emits NodeIDs and never deletes anything.

--tailnet needs a stored Tailscale API credential (a tskey-api-* access token or an OAuth client secret). Without one it fails with auth_error (exit 3) and bootstrap guidance in error.next; it never returns an empty list. It conflicts with --name, --type, --fields, and --verbose (usage_error, exit 2), because those filter this machine's registered services while --tailnet reports tailnet devices.

Register a one-shot proxy or file service, start the daemon if it is not already running, and wait for an exact runtime URL -- this is the fastest path from nothing to a URL. Existing directories expose their contents as file services; a regular file exposes only that selected file and returns its URL, without exposing sibling files or the parent directory listing; a bare port or a host:port target becomes an HTTP proxy. Shares are ephemeral by default, and re-sharing the same target reuses its existing service instead of creating a suffixed orphan node.

bash
tslink share ./build
tslink share ./report.html          # URL points directly to report.html
tslink share 3000
tslink share localhost:8080 --name preview
tslink share ./build --ephemeral=false

On a credential-free first run, the one stdout line is the Tailscale authorization URL, and stderr prints the exact continuation command (tslink url <name> --wait). With --json, this is a successful status:"needs_login" result carrying auth_url -- not an authentication error.

Flags:

FlagDescription
--name <name>Requested service name (DNS label). A matching target reuses it; an unrelated name collision gets a numeric suffix instead of overwriting anything.
--ephemeralUse an ephemeral tailnet node (default true; pass --ephemeral=false for durable state)
--wait <duration>Wait for an exact runtime URL (defaults to 30s; unlike tslink url, no flag is required for share to wait)
--no-daemon-installRequire an already running background service; do not install one
FlagDescription
--ack-unlimited-request-bodyAcknowledge removal of the HTTP request body size limit
--idle-timeoutHTTP keep-alive idle timeout (default 60s)
--max-request-bodyMaximum HTTP upload size (default 32MiB); unlimited requires --ack-unlimited-request-body
--preserve-hostForward this node's canonical external Host (proxy only; recipes choose their default)
--request-header-timeoutHTTP header read timeout (default 10s)
--request-read-timeoutMaximum time without body read progress, not total upload time (default 30s)

Print one already-registered service's exact runtime URL, optionally waiting for it to resolve.

bash
tslink url myapp
tslink url myapp --wait
tslink url myapp --wait --raw

Flags:

FlagDescription
--wait [duration]Wait for an exact runtime URL only when supplied; a bare --wait means 30 seconds
--rawPrint only the URL and one trailing newline, with no JSON envelope; conflicts with --json

If the runtime has not yet reported an exact tailnet hostname and you did not pass --wait, tslink url exits non-zero with error.code url_not_ready rather than printing a placeholder.

Gateway

Start the TSLink gateway. This spins up one embedded tsnet node per registered service and begins serving.

Flags:

FlagDescription
--daemonRun in the background as a daemon process
--control-url <url>Custom control server URL (e.g., Headscale) — overrides the global config
--manage-aclOpt in to ordinary startup tag-owner ensure; separate acknowledged Funnel auto-provisioning remains default-on
--mcpServe the remote MCP control plane on a dedicated tailnet-only node; off by default and requires nonempty owner mcp.allow or explicit mcp.bindings in config.json. See Remote MCP Control Plane
--no-auto-provisionDisable automatic Funnel policy provisioning for every service in this serve process
--no-browserPrint the Tailscale login URL instead of opening a browser
bash
# Run in the foreground
tslink serve

# Run as a background daemon
tslink serve --daemon

# Use a Headscale control server (one-time override)
tslink serve --control-url https://headscale.example.com

On startup, TSLink automatically:

  • Resolves credentials and starts one tsnet node per service.
  • With --manage-acl and a credential with the required scopes, ensure ordinary tag-owner policy. Explicitly public Funnel services provision their shared grant by default unless --no-auto-provision or the service setting disables it. Exact-ownership device cleanup is a separate API operation.
  • Watches registry.json for changes and hot-reloads services.

Stop a running TSLink gateway (foreground or daemon). Reads tslink.pid, verifies the process belongs to TSLink, and waits up to 5 seconds for termination. macOS/Linux use SIGTERM for graceful shutdown; Windows uses process termination and is not graceful.

stop does not remove automatic-start registration. A managed macOS LaunchAgent has KeepAlive and restarts after a stop, with a 30-second throttle; run tslink uninstall first when you need it to stay stopped. A successful graceful stop of the Linux systemd user service leaves it stopped because its restart policy is on-failure. Windows startup registration remains active for the next login until uninstalled. After uninstalling, stop any remaining manual gateway and verify tslink status --json before deleting state.

bash
tslink stop

JSON data.supervision reports the verified manager, installed, autostart, restart_on_exit, and optional detail / evidence / autostart_scope. Read it separately from daemon_running; a live process does not prove autostart or supervision.

Show the current status of the TSLink gateway: daemon state, authentication, and registered service count.

bash
tslink status

Example output:

Code
→ tslink: running (pid 12345)
→ tailnet: authenticated
→ services: 3 registered

Flags:

FlagDescription
--urlsShow the owner-only service endpoint overview
--name <name>Filter --urls by exact registered service name; requires --urls

--urls prints where each service is reachable. It is owner-only output: the endpoints it lists are the ones your own registry defines, so treat the result the way you would treat the registry itself.

Tag Management

Manage ACL tags for your TSLink services. API-key mode can create or fetch tags through the Tailscale API; client-secret-only mode should be validated before relying on tag/device automation.

Subcommands:

SubcommandDescription
listList all registered services and their assigned tags
pullPrint current ACL tags from the Tailscale API
add <service> <tag>Add a tag to a specific service
set <service> <tag>Replace a specific service's tags with one tag
set-default <tag>Set the default tag applied when --tags is not specified
delete-remote <tag> --force --manage-aclRemove the ACL tag owner rule globally from the Tailscale ACL policy via the API after local safety checks and explicit remote ACL opt-in

List all registered services and their assigned tags.

bash
tslink tags list

Print the current ACL tags from the Tailscale API. This remote pull is skipped in client-secret-only mode because it requires an API access token.

bash
tslink tags pull

Add a tag to a specific registered service.

bash
tslink tags add webapp tag:staging

Replace a specific service's tags with one tag.

bash
tslink tags set webapp tag:web

Set the default tag applied to services when --tags is not specified. Persisted in GlobalConfig.DefaultTag.

bash
# Change the default from tag:tsmain to a custom tag
tslink tags set-default tag:myteam

Remove the ACL tag owner rule globally from the Tailscale ACL policy via the API after local safety checks. Both --force and --manage-acl are required because this modifies your tailnet's ACL policy globally and remote ACL mutation is default-off.

bash
tslink tags delete-remote tag:deprecated --force --manage-acl

Maintenance

Reconcile what the registry says against what the tailnet actually holds: retire Funnel exposure whose deadline has passed, delete tailnet devices this machine provably owns and no longer needs, and report this machine's use of the shared Funnel grant without deleting that shared policy.

bash
# Preview -- this is the default
tslink cleanup

# Apply
tslink cleanup --dry-run=false

Flags:

FlagDefaultDescription
--dry-runtruePreview reconciliation without registry or device deletion; set --dry-run=false to apply. Shared ACL grants are never deleted
--manage-aclfalseReport local use of the shared Funnel tag-owner / nodeAttrs grant; never delete the shared grant or query tailnet ACLs
--adopt <hostname>nonePreview or record exact ownership for one literal TSLink-tagged legacy device hostname
--forcefalseConfirm the explicitly named --adopt migration

--dry-run defaults to true, so the bare command deletes nothing. That default is the safety property, not a convenience: deletion here requires durable exact NodeID ownership proof, and hostname matching is discovery-only. A device whose hostname matches but whose NodeID is not proven is reported in devices_protected and never deleted.

--adopt exists for devices created before ownership proof was recorded. It takes one literal hostname, and writing proof requires exactly one TSLink-tagged remote match plus --force --dry-run=false; more than one match is a cardinality conflict rather than a guess. The preview is read-only.

The JSON envelope reports why a cleanup did nothing, which matters more than the fact that it did nothing: device_cleanup_skipped with device_skip_reason distinguishes an untrusted registry.json, a missing ownership ledger, an orphan without retired_at provenance, an unavailable API client, protected hostname-only matches, and a remote failure. NodeIDs never appear in output; hostnames do.

Inspect the local service registry directly, without going through the runtime.

Strictly validate a registry.json and report everything wrong with it, without modifying the file. With no argument it checks the active registry; pass a path to check a file elsewhere, such as a candidate you are about to install or a copy from another machine.

bash
tslink registry check
tslink registry check ./candidate-registry.json --json

The result carries path, schema_version, valid_services, total_services, and an issues[] array whose entries name the offending service by index and name with a stable code and a human message. Every rejected entry is reported in one pass, so valid_services below total_services tells you exactly which services the runtime would drop and why, rather than only the first problem a loader happened to reach.

Invitations

Invite a person to your tailnet, or share one TSLink-owned service device with someone outside it.

Every subcommand here performs an outward-facing mutation through the Tailscale API: a real person receives a real invitation. These are the only TSLink commands whose effect is visible to someone who is not you, which is why each one names its recipient explicitly, requires a user-owned tskey-api- token stored by tslink login, and returns a remote_side_effect_plan object in its JSON envelope recording what was sent.

Device sharing carries a second requirement: TSLink shares only a device it registered itself, matched by exact nodeId. A device it cannot prove it owns is never a candidate, so a mistyped service name fails instead of sharing an unrelated machine.

Subcommands:

SubcommandDescription
user <email>Invite a user to join the tailnet
device <service> <email>Share a TSLink-owned service device with an external user
listList open user and TSLink-owned device invites
revoke <id> --kind <user|device>Revoke a user or device invite
resend <id> --kind <user|device>Resend an emailed user or device invite

Invite a person to join your tailnet.

bash
tslink invite user teammate@example.com
tslink invite user teammate@example.com --role auditor
tslink invite user teammate@example.com --print-link
FlagDefaultDescription
--role <role>memberRole assigned on acceptance: member, admin, it-admin, network-admin, billing-admin, auditor
--print-linkfalseDo not send email; return the API-provided invite URL for self-delivery

--print-link changes who delivers the invitation, not who may accept it. The JSON envelope states the delivery fact rather than implying it: emailed: true when Tailscale sent the mail, emailed: false plus an invite_url when you deliver it yourself. That URL comes back verbatim from the Tailscale API -- TSLink never constructs one -- and it is a bearer credential: whoever holds it can accept.

Share the device backing one registered service with an external user, so they reach that one machine without joining your tailnet.

bash
tslink invite device my-api partner@example.com
tslink invite device my-api partner@example.com --multi-use
FlagDefaultDescription
--print-linkfalseDo not send email; return the API-provided invite URL for self-delivery
--multi-usefalseAllow the device invite to be accepted more than once
--allow-exit-nodefalseAllow the recipient to use the shared device as an exit node

--multi-use and --allow-exit-node each widen what the invitation grants, and each is off by default. A multi-use invite URL stays valid after the first acceptance; an exit-node share lets the recipient route their own traffic through this machine.

List open user invites, and device invites for services this TSLink owns.

bash
tslink invite list
tslink invite list --json
FlagDefaultDescription
--show-urlsfalseInclude bearer invite URLs in human and JSON output

URLs are withheld by default because they are bearer credentials: printing one puts an acceptable invitation into your scrollback and into anything that captures it.

The JSON envelope separates an empty result from a partial one. complete: true means every requested device target was checked without error; false means some device results are missing. device_targets[] carries the per-service outcome, so a checked target with invite_count: 0 reads as "none open" rather than "not reached".

Revoke an open invitation.

bash
tslink invite revoke 12345 --kind user
FlagDefaultDescription
--kind <user|device>requiredInvite namespace

--kind has no default on purpose. User and device invite IDs are separate numeric namespaces, so one number can name an invite in each; requiring the namespace makes the target explicit instead of guessed. Revoking a device invite also requires exact TSLink node ownership proof. revoked: true appears only after the Tailscale API accepted the revocation.

Resend an invitation that was originally created with email delivery.

bash
tslink invite resend 12345 --kind user
FlagDefaultDescription
--kind <user|device>requiredInvite namespace

An invite created with --print-link has no email address and cannot be resent. Resend output never repeats the invite URL either: reprinting a bearer credential would put it on a second surface for no benefit, so the result reports emailed and stops there.

Diagnostics, Access, And Templates

Run local diagnostics and return warning/critical thresholds through the same JSON envelope and exit-code contract as other commands.

Flags:

FlagDescription
--probe-externalProbe non-loopback service targets
--probe-remoteVerify each stored credential against the Tailscale API with one device-list read, recording last_verified in credential-meta.json

Both are off by default because both reach outside this process: --probe-external opens connections to targets that are not loopback, and --probe-remote spends one Tailscale API call per stored credential. A default run stays local and free.

Doctor also reports whether Tailscale SSH is enabled on this node, read from the local tailscaled. The human view prints Tailscale SSH (this node): <state>; the JSON payload carries tailscale_ssh.state and tailscale_ssh.acl_rule_required: true. The check exists because tailscale ssh <host> tslink <command> is the zero-code way to drive this install from another tailnet machine, and that path needs two things that both live in the Tailscale layer: Tailscale SSH enabled on this node, and a tailnet ACL ssh rule admitting the caller. TSLink only detects the first and points at both; it never enables Tailscale SSH and never edits the policy file.

StateFinding codeWhat it says
enabledtailscale_ssh_enabledtailscale ssh <this-host> tslink list --json works once a tailnet ACL ssh rule admits the caller
disabledtailscale_ssh_disabledRun tailscale set --ssh on this machine and add the ACL ssh rule to use the remote path
unknowntailscale_ssh_unknownThe local Tailscale client state could not be read within one second; check tailscale status

All three outcomes are informational. They never change doctor's status or exit code.

Explain how a registered service is reachable, including tailnet endpoint, --allow behavior, and Funnel exposure.

List built-in personal templates.

Show a built-in template definition.

Apply a built-in template. Use --dry-run to preview a plan without writing registry changes.

FlagDescription
--dry-runPreview the template plan without writing the registry
--yesWrite missing template services to the registry
--no-daemon-installApply configuration only; do not install or start the background service

Configuration

Manage global TSLink settings persisted in ~/.config/tslink/config.json.

Subcommands:

SubcommandDescriptionExample
set <key> <value>Set a config valuetslink config set control-url https://hs.example.com
get <key>Get a config valuetslink config get control-url
listList all config valuestslink config list

Available keys:

KeyDescriptionDefault
control-urlCustom control server URL (Headscale)Tailscale default
bash
# Set Headscale control server
tslink config set control-url https://headscale.example.com

# Check current value
tslink config get control-url

# Clear (revert to Tailscale default)
tslink config set control-url ""

# Show all settings
tslink config list

View TSLink daemon log output. By default reads the last 50 lines from the stderr log (tslink.err.log).

Flags:

FlagDescriptionDefault
--last <N>Number of lines to show50
--level <level>Filter by minimum log level (debug, info, warn, error)(none)
--source <src>Log source: out (stdout) or err (stderr)err
bash
# Show last 50 log lines
tslink logs

# Show last 100 lines
tslink logs --last 100

# Show only errors
tslink logs --level error

# Show stdout log
tslink logs --source out

# JSON output
tslink logs --last 20 --json

System

Register TSLink for automatic startup on login. --no-auto-provision preserves the corresponding daemon-wide Funnel choice across managed restarts.

bash
tslink install
  • macOS: Creates a LaunchAgent (~/Library/LaunchAgents/com.tslink.daemon.plist)
  • Linux: Creates a systemd user service (~/.config/systemd/user/tslink.service)
  • Windows: Registers an interactive-user Task Scheduler task and starts a built-in supervisor with daemon crash recovery. --startup is the deliberate fallback without crash restart.
FlagMeaning
--forcemacOS only; unavailable on Linux/Windows. Proceed with an upgrade despite an unavailable launchd domain (may start a second daemon)
--no-auto-provisionDisable automatic Funnel policy provisioning in the installed background service

On macOS, gui/$(id -u) exists only while that user has a desktop (Aqua) session. Availability follows the session, not whether you connected over SSH. A first install falls back to user/$(id -u); an upgrade refuses when a previously used domain cannot be checked, because TSLink cannot prove that domain is empty. The refusal exits 1 with error.code launchctl_domain_unavailable, and its data names the unavailable domain, the exact --force command, and the residual risk.

FlagDescription
--startupUse the Windows Startup fallback without crash restart when Task Scheduler is unavailable

Remove the auto-start registration.

bash
tslink uninstall
FlagMeaning
--forcemacOS only; unavailable on Linux/Windows. Remove the plist despite an unavailable launchd domain (may leave a daemon running)

The same domain rule applies. When no domain confirms the job was unloaded and at least one could not be checked, uninstall keeps the plist and exits 1, so the recovery handle survives. --force removes it anyway and reports how to check for and remove a job that may still be loaded.

Programmatic Access

MCP clients reach the same operations through tslink mcp or the remote MCP control plane.

Every command in the published manifest except the stdio tslink mcp server accepts --json and writes one versioned envelope to stdout, so owner-side automation is the same CLI with one flag. Cobra's help and completion commands are not in the manifest and print plain text. Common automation commands:

Automation actionCurrent entry point
listtslink list --json
addtslink add <name> --proxy <host:port> --json (or --dir, --tcp)
removetslink remove <name> --json
statustslink status --json
doctortslink doctor --json
access_explaintslink access explain <name> --json
template_listtslink template list --json
template_plantslink template apply <template> --dry-run --json
template_applytslink template apply <template> --yes --json
manifesttslink manifest --json
bash
# List services registered on this machine
tslink list --json

# Add a service
tslink add myapp --proxy localhost:3000 --json

# Remove a service
tslink remove myapp --json

# Check status
tslink status --json

# Run read-only diagnostics
tslink doctor --json

# Explain one service's local access model
tslink access explain myapp --json

# Preview, then apply, a built-in template
tslink template list --json
tslink template apply local-web --dry-run --json
tslink template apply local-web --yes --json

--json changes only the output format. tslink add --json follows the same safety guardrails as the human path: Funnel services require --public, and TCP services reject --allow because TSLink does not apply HTTP identity checks to raw TCP streams.

Every --json response uses the versioned envelope described under JSON Output Format: the command payload sits under data, structured errors under error, and command names the command that produced it. A success (tslink list --json on a machine with no services) looks like this:

json
{"type": "tslink.result", "ok": true, "schema_version": 1, "command": "list", "code": 0, "data": {"schema_version": 1, "services": [], "count": 0}}

A usage failure (tslink list --tailnet --verbose --json) looks like this:

json
{"type": "tslink.result", "ok": false, "schema_version": 1, "command": "list", "code": 2, "error": {"code": "usage_error", "message": "--tailnet conflicts with --verbose; --verbose filters this machine's registered services, while --tailnet reports tailnet devices", "next": ["tslink --help"]}}

The same operations are available to MCP clients through tslink mcp (stdio) and the tailnet-only remote control plane started by tslink serve --mcp; see TSLink as an MCP Server.

Run a local Model Context Protocol server over stdio, so an MCP client can drive TSLink as a tool instead of parsing CLI output. It speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and exposes 44 tools in owner sessions; reduced roles expose fewer tools, from share, list, unshare, and status through add, url, tags_*, access_explain, doctor, logs, invite_*, and template_*.

bash
tslink mcp

The MCP process itself opens no network listener and needs no mcp.allow entry; share may start the separate TSLink daemon and its requested tsnet service. Protocol frames go to stdout and diagnostics to stderr, so --json is rejected here: stdout is reserved for frames.

tslink serve --mcp serves role-dependent tools (44 for owner) over HTTPS on a dedicated tailnet-only tsnet node, for MCP clients on other machines in your tailnet. It is off by default and requires nonempty owner mcp.allow or explicit mcp.bindings in config.json.

For client configuration, the full tool schemas, the remote control plane, and a worked example, see TSLink as an MCP Server.

FlagDescription
--appsComma-separated apps visible to the reduced MCP session
--inventoryExplicitly allow a viewer to read all app inventory
--max-durationMaximum per-app people grant duration (reduced operator default: 24h)
--scopeMCP role: owner, viewer, app-operator or people-manager

Print the installed binary's own machine-readable description of itself: every command, every flag, exit codes, error codes, credential sources, and the exported security capability manifest. This is how an agent -- or this documentation site's own CLI parity check -- can confirm what a specific installed build actually supports, instead of trusting prose.

tslink manifest is hidden from tslink --help (it is a machine-facing command, not a human workflow step), but it runs like any other subcommand:

bash
tslink manifest              # full manifest, indented JSON
tslink manifest --compact    # commands, flags, and stable error codes only
tslink manifest --json       # full manifest wrapped in the standard --json envelope

Flags:

FlagDescription
--compactPrint only commands, flags, and stable error codes

Use the manifest from your installed binary when checking command and flag support in automation.

Roadmap / Experimental Commands and Fields

For details on roadmap commands and fields (middleware, Docker integration, admin REST API, custom domain/ACME), see Experimental & Roadmap.

Global Flags

FlagDescription
--jsonVersioned CLI JSON envelope; tslink mcp rejects it because stdout holds protocol frames
--versionPrint the installed version
--helpShow help for any command
bash
tslink --help
tslink add --help
tslink status --json

Exit Codes

All commands return semantic exit codes for programmatic error handling:

CodeMeaningExample
0SuccessCommand completed
1General errorUnexpected failure
2Usage errorInvalid arguments or flags
3Authentication errorMissing or invalid credentials
4Conflicttslink serve when already running
5Not foundtslink url for a nonexistent service when the gateway is running. tslink remove is idempotent: removing an unregistered name is a successful call that removed nothing (removed: false, exit 0)
64Warningtslink doctor completed with warnings
65Criticaltslink doctor found critical problems

JSON Output Format

When --json is passed, CLI commands output a JSON envelope; tslink mcp is the explicit exception:

json
{
  "type": "tslink.result",
  "ok": true,
  "schema_version": 1,
  "command": "status",
  "code": 0,
  "data": {
    "supervision": {
      "manager": "none",
      "installed": false,
      "autostart": false,
      "restart_on_exit": false,
      "detail": "No launchd ownership/autostart could be verified. Run: tslink install"
    },
    "daemon_running": false,
    "daemon_state": "absent",
    "daemon_pid": 0,
    "ownership_proof_available": true,
    "authenticated": false,
    "credential_stored": false,
    "credentials": {
      "api_key": {
        "present": false,
        "expiry_state": "none",
        "early_warning": {
          "state": "none",
          "source": ""
        }
      },
      "client_secret": {
        "present": false,
        "expiry_state": "none",
        "early_warning": {
          "state": "none",
          "source": ""
        }
      }
    },
    "credential_expiry_state": "none",
    "node_authorized": false,
    "authorized_service_count": 0,
    "auth_status": "not_authenticated",
    "service_count": 0,
    "services": [],
    "guest_links": [],
    "access_log": {
      "current": false,
      "enabled": true,
      "last_write": null,
      "drops": 0,
      "size_bytes": 0,
      "error": "access_log_not_started",
      "updated_at": "0001-01-01T00:00:00Z"
    },
    "portal": {
      "enabled": false,
      "state": "disabled"
    },
    "alerts": {
      "notifier": "none",
      "events": []
    }
  }
}

On error, error is a structured object with a stable string code. This is the actual prerequisite error for tslink url nonexistent --json with no gateway running: it returns exit 1 / daemon_not_running before looking up the service. With a running gateway, a missing service can reach exit 5 / not_found:

json
{
  "type": "tslink.result",
  "ok": false,
  "schema_version": 1,
  "command": "url",
  "code": 1,
  "error": {
    "code": "daemon_not_running",
    "message": "TSLink is not running; install and start its background service with 'tslink install'",
    "next": [
      "tslink install"
    ]
  }
}

Apps and People

List, detect and share known self-hosted applications with recipes. These commands do not install or configure the apps.

List the versioned recipe catalog and app-side configuration advice.

Read-only, bounded GET fingerprints of numeric loopback HTTP listeners. Partial results report complete: false; matches do not prove authentication or health.

Preview an app recipe by default; apply with --yes. --dry-run wins. Existing names remain unchanged. Review app login, proxy trust, health path and host port before applying.

FlagDescription
--ack-unlimited-request-bodyAcknowledge removal of the HTTP request body size limit
--allowComma-separated private HTTP identities
--control-urlPer-service control server URL
--dry-runPreview without writing, even with --yes
--ephemeralUse an ephemeral node
--force-unsafe-publicDANGER: override never-public recipe policy; may expose host control or private data to everyone
--funnelPublish on the internet; requires --public and recipe safety review
--funnel-ttlShared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused.
--health-bodyExpected body substring within first 64 KiB (proxy only; avoid secrets in argv)
--health-intervalBackend probe interval, 10s to 1d
--health-pathHTTP business probe path (default /; proxy only)
--health-status-maxHighest expected HTTP probe status (proxy only)
--health-status-minLowest expected HTTP probe status (proxy only)
--health-timeoutBackend probe timeout, 100ms to 30s
--idle-timeoutHTTP keep-alive idle timeout (default 60s)
--max-request-bodyMaximum HTTP upload size (default 32MiB); unlimited requires --ack-unlimited-request-body
--nameOverride the recommended service name
--no-auto-provisionDisable automatic Funnel policy provisioning (requires --funnel)
--no-daemon-installSave configuration only; do not install or start the background service
--preserve-hostForward this node's canonical external Host (proxy only; recipes choose their default)
--proxyOverride the loopback HTTP(S) target (host port, not container port)
--publicAcknowledge public internet exposure (requires --funnel)
--request-header-timeoutHTTP header read timeout (default 10s)
--request-read-timeoutMaximum time without body read progress, not total upload time (default 30s)
--tagsComma-separated ACL tags
--yesApply the reviewed recipe plan without replacing existing entries
bash
tslink apps share jellyfin
tslink apps share jellyfin --yes

Manage named-person access to private HTTP/file apps. TCP and public Funnel cannot enforce these grants. See people sharing.

Grant a new person access; --apps is required. The first grant scopes the selected apps. --invite explicitly creates per-app device invitations; --print-links reveals bearer links. Local grants need no token, but invitations need a user-owned API token.

FlagDescription
--appsComma-separated private HTTP/file apps, or all current supported apps
--forGrant lifetime; presets 1h, 8h, 24h, 3d, 7d; relative, until <date/time>, or never with --ack-never; default 24h, update omission preserves deadlines
--inviteCreate or resume single-use per-app device invitations (requires a user-owned API token)
--print-linksExplicitly include bearer invitation links in output and the guide
bash
tslink people add alice@example.com --apps photos --for 7d
FlagDescription
--qrRender the exact portal URL (or first app when portal disabled) as a terminal QR
--qr-inviteEncode this app's bearer invitation instead; requires --print-links and --qr or --qr-png; QR is a credential
--qr-pngWrite a private QR PNG to an existing directory; JSON contains payload text only
--untilAbsolute grant deadline: RFC3339, YYYY-MM-DD or YYYY-MM-DDTHH:MM (local without offset); conflicts with --for

Read local people, grants, deadlines and revocation records. It creates no invitations.

Update app scope, deadline or explicitly resume invitations. App-only updates preserve retained deadlines and give newly added apps 24h; --for applies to all selected apps. Inspect complete and per-app state/code. Unknown POSTs require owner-verified reconciliation; replacement preserves grants and deadlines.

FlagDescription
--appsComma-separated private HTTP/file apps, or all current supported apps
--forGrant lifetime; presets 1h, 8h, 24h, 3d, 7d; relative, until <date/time>, or never with --ack-never; default 24h, update omission preserves deadlines
--inviteCreate or resume single-use per-app device invitations (requires a user-owned API token)
--print-linksExplicitly include bearer invitation links in output and the guide
--reconcile-inviteAfter verifying an unknown POST, associate app=id or confirm app=none; requires --invite
--replace-inviteOwner-confirmed app=recorded-id replacement after remote absence; preserves grants/deadlines; requires --invite
FlagDescription
--qrRender the exact portal URL (or first app when portal disabled) as a terminal QR
--qr-inviteEncode this app's bearer invitation instead; requires --print-links and --qr or --qr-png; QR is a credential
--qr-pngWrite a private QR PNG to an existing directory; JSON contains payload text only
--untilAbsolute grant deadline: RFC3339, YYYY-MM-DD or YYYY-MM-DDTHH:MM (local without offset); conflicts with --for

Save a local deny record before cleaning up pending invitations. No-token denial works; remote cleanup can be incomplete. Accepted network shares may remain. New HTTP/file requests are denied; existing streams may finish.

FlagDescription
--reconcile-inviteAfter verifying an unknown POST, associate app=id or confirm app=none before cleanup

Access workflows and history

Read retained history and summaries; no remote API or new files/locks. See access history.

FlagType / defaultDescription
--appstringFilter by app/service
--decisionstringFilter allowed or denied
--limitint / 100Maximum returned events (1..10000); summaries cover all matches
--sincestringPositive duration (24h) or RFC3339 lower bound
--untilstringRFC3339 upper bound (inclusive)
--whostringFilter by login, node name or tag

Set path privacy for one app. Global off overrides it; full can store sensitive app paths. inherit clears local overrides.

Set now + duration for one person grant (--person) or Funnel (omitted). Always returns JSON. Expired state requires --regrant; revocation stays denied. See durations.

FlagType / defaultDescription
--ack-neverbool / falseAcknowledge permanent tailnet-member access; never is refused for public/guest
--forstringNew lifetime from now; presets 1h, 8h, 24h, 3d, 7d; relative or until <date/time>
--personstringPerson login whose grant for this service is changed; omit for Funnel
--regrantbool / falseExplicitly reactivate an expired grant or Funnel TTL; revoked people stay revoked
--untilstringAbsolute deadline: RFC3339, YYYY-MM-DD or YYYY-MM-DDTHH:MM; local unless offset supplied

Manage browser guest grants for one HTTP proxy app. Public Funnel requires a mandatory guest gate.

Requires --for and explicit --public when enabling the gate. Bring the private app online before --print-link; bearer disclosure is one-time. File/TCP and existing open Funnel are refused. See guest links.

FlagType / defaultDescription
--forstringLifetime: 1h, 8h, 24h, 3d, 7d; never is refused
--labelstringOwner's label for this link
--pinbool / falseRead a PIN from hidden terminal input or stdin; never pass it in argv
--print-linkbool / falseExplicitly return the one-time bearer link and sendable message
--publicbool / falseAcknowledge public internet reachability through Funnel with mandatory guest authentication

List non-secret grants, deadlines and local usage estimates; never returns bearer tokens.

Permanently revoke this ID. Tracked guest streams are cancelled; a bounded request already accepted may finish.

Inspect one grant without recovering its link or PIN.

Owner reads bounded intent/completion receipts. Missing completion means unknown outcome; no complete-audit guarantee.

Manage the private per-host portal. It does not aggregate hosts or change app registrations.

Stop only the portal listener; retain enrollment state and owner/admin identities.

Requires --owner; saves private portal configuration for daemon reconciliation. Read the exact runtime URL from status --urls. Remote MCP additionally requires the current portal owner; first ownership/recovery is local.

FlagType / defaultDescription
--adminsstringSlice / []Additional administrator logins; these identities can open every private app
--funnelbool / falseRefused: the portal must stay Tailnet-only
--hostnamestring / homePortal node hostname
--ownerstringOwner's verified Tailscale login (required)

Review access requests from human tailnet members in the portal. See portal and requests.

Requires --for. Grant one app for the owner-chosen lifetime; identical retries replay without renewal. Remote owner/people-manager calls also require the current portal owner, app/duration scope and pre-existing people for reduced roles.

FlagType / defaultDescription
--ack-neverbool / falseAcknowledge permanent member access; guests remain finite
--forstringOwner-chosen lifetime; presets 1h, 8h, 24h, 3d, 7d

Decline one request; identical retries replay the original decision. Remote calls have the same portal-owner check.

FlagType / defaultDescription
--reasonstringOptional reason (500 characters), shown to the visitor

Read the durable inbox; retention maintenance may write. Notes are untrusted data.

All normal command results support inherited --json; extend always uses JSON. MCP stdout is reserved for protocol frames.

Table of Contents

OverviewAuthenticationtslink logintslink logoutService Managementtslink add <name>tslink remove <name>tslink listtslink list --tailnettslink share <path|port|host:port>tslink url <name>Gatewaytslink servetslink stoptslink statusTag Managementtslink tagstslink tags listtslink tags pulltslink tags add <service> <tag>tslink tags set <service> <tag>tslink tags set-default <tag>tslink tags delete-remote <tag> --force --manage-aclMaintenancetslink cleanuptslink registrytslink registry check [path]Invitationstslink invitetslink invite user <email>tslink invite device <service> <email>tslink invite listtslink invite revoke <id> --kind <user|device>tslink invite resend <id> --kind <user|device>Diagnostics, Access, And Templatestslink doctortslink access explaintslink template listtslink template showtslink template applyConfigurationtslink configtslink logsSystemtslink installtslink uninstallProgrammatic Accesstslink mcptslink manifestRoadmap / Experimental Commands and FieldsGlobal FlagsExit CodesJSON Output FormatApps and Peopletslink appstslink apps listtslink apps detecttslink apps sharetslink peopletslink people addtslink people listtslink people updatetslink people removeAccess workflows and historytslink access logtslink access path <app> <prefix|full|off|inherit|true|false>tslink extend <service>tslink guesttslink guest create <app>tslink guest listtslink guest revoke <id>tslink guest show <id>tslink mcp-audittslink portaltslink portal disabletslink portal enabletslink requeststslink requests approve <id>tslink requests deny <id>tslink requests list