Publish local dev servers on your tailnet, automatically.
Start a dev server on your laptop and your phone can open it at
https://<machine>.<tailnet>.ts.net:<port>/. You don't run a command, remember
a port, or configure each project. Stop the server and the mapping goes away.
It's meant for developing from a phone. If you turn notifications on, the URL arrives on Telegram (or any webhook) as soon as the server is up. Notifications are off by default, and the daemon works fine without ever sending a message.
Recorded with VHS from docs/demo.tape. It runs in a container with no tailnet, so a stand-in answers for tailscaled and the hostname is made up. The ts-autoserve binary is the real one, built from this repo.
A small daemon polls the machine's listening TCP sockets (lsof on macOS,
ss on Linux). When a port it cares about appears, it writes a tailscale serve
mapping through tailscaled's local API. When the port goes away, it removes the
mapping.
Tailscale handles TLS and identity. Only devices in your tailnet can reach the published ports, and only if your tailnet policy lets them.
Publishing everything that listens is a bad idea. A laptop runs plenty of local infrastructure (editor bridges, sync daemons, debug ports) that has no business on the network. Checking whether a port answers HTTP doesn't help either, because those daemons answer HTTP too.
So a port is judged by two tests:
- Is it a known dev-server port? Covers 3000-3010, 5173-5183, 4200, 4321, 8000-8010, 8080-8090, 6006, 8888, 19000-19006 and a few more.
- Does its process descend from a coding agent? Matches Claude Code,
Codex, Cursor, Windsurf, Antigravity (
agy), aider, opencode, goose, Devin, Copilot, Grok and anything else you add to the pattern. The test looks at process ancestry, sonpm run devstarted inside an agent counts on any port. Only the ancestors are matched, not the listening process itself, so a background daemon that happens to have an agent's name in its path doesn't count as a dev server.
The mode decides how the two tests combine. Set it with mode: in the config or
-mode on the command line:
| Mode | Publishes | Good for |
|---|---|---|
both |
a port that passes either test (the default) | a laptop you develop on |
dev |
only the known dev-server ports | predictability: nothing new is published unless you list the port |
agent |
only ports whose process descends from a coding agent | a machine where agents do the work and you want nothing else exposed |
all |
every port in port_range |
short debugging sessions; noisy, so read SECURITY.md first |
exclude_ports always wins, whatever the mode. The defaults exclude Chrome's
remote-debugging port and a few others that would hand over more than a preview.
The agent test ignores the ephemeral range (agent_ephemeral_from, 49152 by
default). An agent's own RPC sockets live there and are replaced on every call,
so publishing them means a URL that dies before anyone opens it and a
notification each time; a dev server the agent starts binds a stable low port
and still gets through. A port in dev_ports is published even when it falls in
the range, and agent_ephemeral_from: 0 turns the check off.
Let a coding agent install it (a prompt to paste; it installs in agent mode)
The agent you already have open can do the whole thing:
Install ts-autoserve on this machine so the dev servers you start are reachable
from my phone over Tailscale:
1. Download the ts-autoserve binary for this OS and CPU from
https://github.com/breakzplatform/ts-autoserve/releases/latest
(ts-autoserve_darwin_all.tar.gz on macOS, ts-autoserve_linux_amd64.tar.gz
or ts-autoserve_linux_arm64.tar.gz on Linux), check it against
checksums.txt, and put it in ~/.local/bin, making sure that is on PATH.
2. Run `ts-autoserve -once -dry-run -v` and show me what it would publish.
If it fails because this user cannot change the serve config, stop and ask
me to run `sudo tailscale set --operator=$USER` -- that needs my password,
do not try it yourself.
3. Run `ts-autoserve service install -mode agent`. That publishes only ports
opened by processes you started, and nothing else on my machine.
4. Run `ts-autoserve service status` and tell me the URL pattern my ports will
appear at.
Do not add a Telegram token or any other credential unless I give you one.
-mode agent makes sense here. When an agent runs the installer, the servers
worth publishing are the ones it starts, and a port you opened yourself stays
off the tailnet until you say otherwise. If you want your own dev servers
published too, change mode: in the config to both.
You need Tailscale running on the machine, with HTTPS certificates turned on for your tailnet, since Serve uses them.
Download the binary for your platform from the
latest release
and put it somewhere on your PATH:
# macOS: one universal binary for Apple Silicon and Intel
curl -fsSL https://github.com/breakzplatform/ts-autoserve/releases/latest/download/ts-autoserve_darwin_all.tar.gz | tar -xz ts-autoserve
# Linux on x86-64 (use ts-autoserve_linux_arm64.tar.gz on ARM)
curl -fsSL https://github.com/breakzplatform/ts-autoserve/releases/latest/download/ts-autoserve_linux_amd64.tar.gz | tar -xz ts-autoserve
mkdir -p ~/.local/bin && mv ts-autoserve ~/.local/bin/ # add it to PATH if it isn'tEach release lists SHA-256 sums in checksums.txt. The macOS binary isn't
notarized. That doesn't matter when you download it with curl as above, but if
you download it with a browser, macOS will refuse to run it until you clear the
quarantine flag with xattr -d com.apple.quarantine ts-autoserve.
To build it from source instead (Go 1.27 or newer):
go install github.com/breakzplatform/ts-autoserve/cmd/ts-autoserve@latestGrant your user permission to change the serve config (once per machine):
sudo tailscale set --operator=$USERSee what it would do without changing anything:
ts-autoserve -once -dry-runThen install it as a service (single command, no files to copy):
ts-autoserve service install # launchd on macOS, systemd --user on Linux
ts-autoserve service status # installed? running? which file, which config?Once it runs, ts-autoserve status lists what it published, with each URL and
the process behind it (see status).
To pick a mode other than both at install time:
ts-autoserve service install -mode agent # publish only what an agent startedThat writes mode: into the config file, where you can see it and change it
later without reinstalling. The installer never rewrites an existing config. If
yours already sets a different mode, the installer tells you and leaves the
choice to you.
The installer writes a service file that points at the binary you ran, starts
the service, and keeps it running. It comes back at login and restarts if it
dies. On a headless Linux box that nobody logs into, add
sudo loginctl enable-linger $USER so the user service starts at boot.
The files under packaging/ are the same definitions, if you'd rather install
them by hand. Change the binary path in them to wherever you put it.
Everything has a default, so the file is optional.
mode: both # dev | agent | both | all
interval: 5s # how often to poll
grace: 2 # polls a port may be missing before it is withdrawn
dev_ports: ["3000-3010", "5173-5183", "8080-8090"]
exclude_ports: ["9222", "5000"]
port_range: ["3000-9999"] # only used by mode "all"
agent_pattern: "claude|codex|cursor|windsurf|antigravity|\\bagy\\b|aider|opencode|goose|devin|copilot|\\bgrok\\b"
agent_ephemeral_from: 49152 # in agent mode, ignore ports from here up; 0 disables
# Optional. KEY=value lines read into the environment at startup, so token_env
# can name a variable kept in a file you already have. Variables set in the
# real environment win. Values may be quoted; $VAR is expanded.
env_file: ~/.secrets/env
# Optional. With this block absent, nothing is ever sent anywhere.
notify:
telegram:
enabled: true
token_env: TELEGRAM_BOT_TOKEN # keep the token out of the file
chat_id: "123456789"
webhook:
enabled: false
url: https://example.com/hookThe daemon looks for the file in this order:
$XDG_CONFIG_HOME/ts-autoserve/config.yaml~/.config/ts-autoserve/config.yaml- the platform config dir (
~/Library/Application Support/ts-autoserve/config.yamlon macOS)
-config overrides the search.
The daemon also keeps a state file at $XDG_STATE_HOME/ts-autoserve/state.json
(or ~/.local/state/ts-autoserve/state.json) that records which mappings it
created. Don't edit it. If you delete it, the daemon forgets which mappings were
its own and won't clean up any of them.
The webhook receives one JSON object per event:
{"kind":"up","port":5173,"url":"https://laptop.example-tailnet.ts.net:5173/","proc":"node","source":"host","text":"node up on port 5173\nhttps://..."}A service doesn't inherit your shell's environment, so a token named by
token_env has to reach it another way:
- Copied at install: export the variable before running
service install. The installer copies its current value into the service definition, which is mode 600. - Read from a file: point
env_file:at aKEY=valuefile you already keep. The daemon reads it at startup and nothing is copied, so rotating a token means editing that file and restarting the service.
Each kind of event has a Go template for
its text. That text is what Telegram shows and what the webhook sends as text.
Set a template to replace the built-in one, or set it to "" to stop sending
that kind of event.
notify:
messages:
up: "🟢 {{.Label}} → {{.URL}}" # built-in: "{{.Label}} up on port {{.Port}}\n{{.URL}}"
down: "" # muted
start: "back up, serving {{join .Ports}}"A template sees .Kind (up, down, start), .Port, .URL, .Proc,
.Source, .Ports (on start, every port already served) and .Label (the
process name, or "a local server"). join lists ports as 3000, 5173.
If the receiving end expects a different JSON shape, webhook.body replaces the
whole body. It sees the same fields plus .Text, and json quotes a value
safely:
notify:
webhook:
enabled: true
url: https://discord.com/api/webhooks/...
body: '{"content": {{json .Text}}}'
content_type: application/json # the defaultts-autoserve [flags] run the daemon
ts-autoserve service install install and start it as a user service
ts-autoserve service uninstall stop the user service and remove it
ts-autoserve service status is the service installed and running?
ts-autoserve status [-json] published URLs and the process behind each
ts-autoserve version print version
With no command, it runs the daemon in the foreground, which is handy for watching it work before you hand it to the OS.
| Flag | Meaning |
|---|---|
-config PATH |
config file (default: first path that exists, see above) |
-mode MODE |
dev, agent, both or all; overrides the config for this run, and on service install is written to the config |
-once |
single pass, then exit |
-dry-run |
report what would change, change nothing |
-v |
debug logging |
-json |
with status: print JSON instead of text |
status shows what this machine publishes right now: every HTTPS port
tailscaled serves, its URL, where it forwards to, who published it, and the
process listening behind it. It only reads; it never changes the serve config.
$ ts-autoserve status
node: laptop.example-tailnet.ts.net
service: running (active)
published:
https://laptop.example-tailnet.ts.net:3000/ -> 127.0.0.1:3000 (ts-autoserve) node (pid 812)
https://laptop.example-tailnet.ts.net:5173/ -> 127.0.0.1:5173 (ts-autoserve) node (pid 913)
https://laptop.example-tailnet.ts.net:8443/ -> 127.0.0.1:9000 (manual) not listeningts-autoserve marks a port this daemon published; manual is a mapping you
set up yourself, which the daemon never touches. The process comes from the
same discovery the daemon uses (lsof on macOS, ss on Linux), looked up on
the local port the mapping forwards to. not listening means nothing local
listens there right now.
status -json prints the same report for scripts and agents:
{
"version": 1,
"node": "laptop.example-tailnet.ts.net",
"endpoints": [
{
"port": 3000,
"url": "https://laptop.example-tailnet.ts.net:3000/",
"proxy": "http://127.0.0.1:3000",
"managed": true,
"process": {"pid": 812, "name": "node"}
},
{
"port": 8443,
"url": "https://laptop.example-tailnet.ts.net:8443/",
"proxy": "http://127.0.0.1:9000",
"managed": false,
"process": null
}
],
"service": {
"installed": true,
"running": true,
"detail": "active",
"file": "/home/dev/.config/systemd/user/ts-autoserve.service"
}
}| Field | Type | Meaning |
|---|---|---|
version |
number | Schema version, 1. It changes only when a field is removed or changes meaning; new fields can appear without it changing, so ignore keys you don't know. |
node |
string | This machine's MagicDNS name. |
endpoints |
array | Every HTTPS port tailscaled serves, sorted by port. Empty array when nothing is served. |
endpoints[].port |
number | The port on the tailnet. |
endpoints[].url |
string | Where to open it: https://<node>:<port>/. |
endpoints[].proxy |
string | Where the port forwards to, such as http://127.0.0.1:3000. Empty when the mapping is not a reverse proxy (static text or files). |
endpoints[].managed |
boolean | true if ts-autoserve published the port, false for a mapping set up by hand. |
endpoints[].process |
object or null | The process listening on the local port in proxy. null when nothing local listens there, or when proxy is not a loopback address. Added in 0.2.0. |
endpoints[].process.pid |
number | Its process ID, 0 when the OS won't say (on Linux, ss only names processes of the user running status). |
endpoints[].process.name |
string | Its command name as lsof or ss reports it, empty when unknown. |
service.installed |
boolean | The launchd agent or systemd user unit exists. |
service.running |
boolean | It is running now. |
service.detail |
string | What launchd or systemd reports, such as pid 42 or active. |
service.file |
string | Path of the service definition, whether or not it exists. |
status exits non-zero when tailscaled is unreachable. If listing local ports
fails, it prints a warning on stderr and reports every process as null.
- Touch mappings it didn't create. A
tailscale serveyou set up by hand is left alone: the daemon never publishes over it or withdraws it, whether or not its server is running. The serve config doesn't record who made a mapping, so the daemon relies on its own state file. Anything it didn't write down as its own is yours, including a mapping you make while it's running. - Expose anything publicly. It only uses Serve (tailnet-only), never Funnel.
- Leave its own mappings behind. On shutdown it withdraws what it published. On startup it clears what a previous run left behind, but only mappings that run recorded as its own, and only where the port has stopped listening.
ts-autoserve doesn't do anything tailscale serve can't. It runs Serve for you,
as servers come and go. How it compares with doing that by hand, and with
tsdproxy, the best-known tool that
automates Tailscale proxies (compared with its latest stable release, v2.3.4):
| ts-autoserve | tailscale serve |
tsdproxy | |
|---|---|---|---|
| What you do per service | nothing | one command per port | add Docker labels, or an entry in a YAML list |
| Where it's reachable | <machine>.<tailnet>.ts.net:<port> |
same | its own tailnet machine, <name>.<tailnet>.ts.net |
| When it's removed | when the port stops listening | when you turn it off (--bg survives reboots) |
when the container stops or its list entry is removed |
| What it needs | operator permission on tailscaled | same | an auth key, an OAuth client or a login per proxy, plus the Docker socket for labels |
| Public (Funnel) | no (opt-in per port is planned) | with tailscale funnel |
opt-in per port |
| Extras | Telegram or webhook notifications | Tailscale Services, for a name per service | dashboard, TCP/UDP, webhook notifications, REST API |
Use plain tailscale serve --bg when you have one or two ports that never
change. Use tsdproxy for long-running containers that deserve their own name and
device. ts-autoserve is for dev servers, which move ports, restart all day, and
aren't worth naming.
- v0.1: host ports, macOS and Linux, Telegram and webhook notifications. Prebuilt binaries since v0.1.1.
- v0.2:
statusandstatus -json: each published URL with the process behind it, in text or in a documented JSON schema. The systemd unit is escaped properly, and the tailscaled client and the service installer have tests. - v0.3: Docker containers with published ports, discovered through the Docker API and mapped the same way. That means one host with one port per service, not one tailnet device per container.
- v0.4: opt-in Funnel per port, and Tailscale Services so a service can get its own MagicDNS name instead of sharing the node's.
Publishing ports automatically has risks. Read SECURITY.md before running it on a machine that has more than dev servers on it.
MIT. See LICENSE.
