tailscale serve: the commands that matter
Every tailscale serve command you will use, checked on Tailscale 1.102.4: foreground versus background, HTTPS ports, path routing, static files and raw TCP.
What tailscale serve does
tailscale serve turns a service that listens on 127.0.0.1 into an HTTPS URL that only your tailnet can reach. Tailscale gives the node a name under MagicDNS, issues a certificate for that name, and proxies requests from https://vps.tail1234.ts.net to the local port you chose. Nothing is opened on the public interface, so the VPS firewall stays closed and there is no nginx to configure.
This page is the command reference. The decision of when to keep a service tailnet-only and when to publish it is covered in serve versus funnel. If the word tailnet is new to you, start with what Tailscale is and how the pieces fit and come back.
Which syntax this guide teaches
The serve CLI (command-line interface) was rewritten in Tailscale 1.52. Every command below uses the current form, where the flags come first and the target comes last. The commands and the quoted output were checked against the help text and CLI source of Tailscale 1.102.4, the stable release as of September 2026. Run tailscale version and confirm you are on 1.52 or later before copying anything.
The old form put a mode and a mount point before the target, like tailscale serve https / http://127.0.0.1:3000. A current client refuses that and prints on stderr:
Error: the CLI for serve and funnel has changed. You can run the following command instead:followed by the translated command and a link to the Tailscale knowledge base. Tailscale keeps a separate page for the legacy syntax, which is why so many snippets copied from forums no longer run. If a command you found has a / sitting on its own between the mode and the target, it is the old syntax.
Prerequisites you hit before the first command
Serve needs a few things in place, and the CLI only helps you with one of them.
Tailscale installed and connected on the VPS. tailscale status should list the node and its peers. If the package itself will not install, the common Tailscale install errors on Ubuntu are covered separately.
MagicDNS on. In the admin console, open the DNS page and enable MagicDNS. That gives the node its hostname.tailnet.ts.net name. The certificate is issued for that exact name, so without MagicDNS there is nothing to issue it for.
HTTPS certificates on. On the same DNS page, enable HTTPS certificates. If you skip this and run an HTTPS serve, the CLI does not fail. It prints a short message and a login.tailscale.com URL, then waits. Open the URL, approve the change, and the CLI prints Success. and continues with your original command. That interactive step is only triggered for HTTPS handlers. The --http and --tcp modes do not need certificates at all.
Two more things are easy to miss. On Linux the CLI talks to tailscaled over a root-owned socket, so either prefix every command with sudo or run sudo tailscale set --operator=$USER once to let your own user manage it. And access control lists (ACLs) apply to serve as they do to any other port. A peer that is not allowed to reach the node on port 443 in your tailnet policy gets a timeout, even though serve is listening.
Serve a local port: foreground versus --bg
Take an app on port 3000. The shortest possible command is:
sudo tailscale serve 3000Available within your tailnet:
https://vps.tail1234.ts.net
|-- / proxy http://127.0.0.1:3000
Press Ctrl+C to exit.A bare port number is expanded to http://127.0.0.1:3000. The URL has no port suffix because 443 is the HTTPS default. Open it from any device on the tailnet and you reach the app. The first HTTPS request takes a few seconds longer than the rest, because the certificate for the node's name is requested from Let's Encrypt on that first connection and cached after it.
This is foreground mode, and it lives exactly as long as the terminal does. The CLI opens a watch session to tailscaled and the handler is stored under that session ID, so when the session ends the handler is removed with it. Ctrl+C ends it. So does closing the SSH window, and so does a reboot. That is by design. Foreground serve is for showing a coworker something for ten minutes.
For anything that should still be there tomorrow, add --bg:
sudo tailscale serve --bg 3000Available within your tailnet:
https://vps.tail1234.ts.net
|-- / proxy http://127.0.0.1:3000
Serve started and running in the background.
To disable the proxy, run: tailscale serve --https=443 off--bg writes the handler into the node's persistent serve config. It survives the shell closing. It survives a reboot, and it survives tailscale down followed by tailscale up. The last line of the output is the exact command that undoes it. Keep it, because the off form needs the same flags you used to create the handler.
A target does not have to be a bare port. localhost:3000 and http://127.0.0.1:3000 mean the same thing. A local service that already speaks TLS with a self-signed certificate is reached with https+insecure://localhost:8443, which tells the proxy to skip certificate checks on the inside hop. A Unix socket works as unix:/run/myservice.sock.
Choose the HTTPS port, or serve plain HTTP
Serve defaults to HTTPS on 443. To use another port, pass --https:
sudo tailscale serve --bg --https=8443 3000The URL becomes https://vps.tail1234.ts.net:8443. Any port works for serve. The fixed list of 443, 8443 and 10000 that people remember applies to Funnel, not to serve. One port holds one kind of listener, though: a port already used for TCP forwarding cannot also carry a web handler, and the CLI refuses with cannot serve web; already serving TCP.
Plain HTTP inside the tailnet is available with --http:
sudo tailscale serve --bg --http=80 3000This needs no certificate and no HTTPS setting. The traffic still travels inside the WireGuard tunnel, so it is encrypted on the wire between peers. What you lose is the browser's secure context, and what HTTPS protects beyond the wire still applies once the app sets cookies or registers a service worker. Use HTTPS unless the client genuinely cannot do TLS.
Path-based routing to more than one local service
One hostname can front several apps, split by mount path. Each command adds one handler on the same port:
sudo tailscale serve --bg 3000
sudo tailscale serve --bg --set-path=/api 8080
sudo tailscale serve --bg --set-path=/grafana http://127.0.0.1:3001/grafanaThe first line mounts at / because that is the default. --set-path takes the path with or without a leading slash; the CLI adds the slash if it is missing. Check the result with sudo tailscale serve status, which prints the hostname once and then one |-- line per mount path. The longest matching path wins, so /api/users goes to port 8080 and everything else goes to port 3000.
The rule you must know: serve removes the mount path before it forwards the request. A request for /api/users arrives at port 8080 as /users. That is right for an app that expects to live at its own root. It is wrong for an app that has been configured to live under a prefix, which is why the Grafana line above puts /grafana on the target too. The proxy strips /grafana from the incoming path, then joins the target's own path back on, so Grafana receives /grafana/login, which is what it was told to expect. If an app behind --set-path returns 404 for every route except the first page, this is the cause.
Serve also adds identity headers to every proxied request, such as Tailscale-User-Login and Tailscale-User-Name. The app can read who is calling without a login page of its own, because the tailnet already verified that identity at the WireGuard layer before the packet reached the proxy.
Serve a static directory or a single file
The target can be an absolute filesystem path instead of a port.
sudo mkdir -p /srv/public
echo "hello from the vps" | sudo tee /srv/public/index.html
sudo tailscale serve --bg /srv/publicA directory is served by Go's standard file server. If it contains index.html, that file answers at the root. If it does not, the visitor gets a plain listing of links, one per file, so do not point serve at a directory that holds anything you would not share. Files are read by tailscaled, which runs as root on a standard Linux install, so file permissions will not stop it.
A single file is mounted at one exact path and nothing else:
sudo tailscale serve --bg --set-path=/report.html /srv/public/report.htmlOnly https://vps.tail1234.ts.net/report.html returns it. Any other path under that handler is a 404. Relative paths are rejected, so write the full path from /.
There is also a text handler for a fixed string. sudo tailscale serve --bg text:"back at 09:00 UTC" is the fastest way to put a maintenance notice in front of a tailnet without touching a web server.
Plain TCP passthrough with --tcp
Not everything speaks HTTP. A database in Docker bound to 127.0.0.1:5432 can be exposed to the tailnet as raw TCP:
sudo tailscale serve --bg --tcp=5432 tcp://127.0.0.1:5432Write the target as a tcp:// URL with a port, which is the form the documentation uses. The foreground summary lists the listener as tcp:// on the node's name and on each of its tailnet IPs, with a |--> line for the local destination. From another node:
psql -h vps.tail1234.ts.net -p 5432 -U appRaw TCP is not wrapped in TLS by serve. That is fine here, because the bytes are already inside the WireGuard tunnel. The other mode, --tls-terminated-tcp=443 tcp://127.0.0.1:9899, has serve terminate TLS with the node's certificate and hand plaintext to the local port, for a non-HTTP service you want reachable under the .ts.net name with a valid certificate.
--set-path does not combine with TCP. The CLI refuses with cannot mount a path for TCP serve, because a TCP forwarder never reads an HTTP path.
If the service could simply bind to the node's tailnet IP, you would not need this at all. Serve is useful when the service is locked to loopback, as it is for containers published on 127.0.0.1 from a compose file, and nothing on the public side is ever opened, which is the reason to be using Tailscale instead of forwarding ports on a VPS.
Check what is running: tailscale serve status
sudo tailscale serve statusWith nothing configured it prints No serve config. Otherwise each listener is shown with its URL and (tailnet only) or (Funnel on), followed by its handlers in the same |-- form the foreground output uses. Add --json for the raw config, which is what you want in a script or a backup:
sudo tailscale serve status --jsonRun status before every off. It tells you the exact port and path you need to name.
Turn one thing off, or reset everything
The off form repeats the flags that created the handler. The target is optional:
sudo tailscale serve --https=443 --set-path=/api off
sudo tailscale serve --https=8443 off
sudo tailscale serve --tcp=5432 offName the wrong port or path and you remove nothing, or the wrong thing, which is why status comes first. To clear every handler on the node at once:
sudo tailscale serve resetreset prints nothing. Run status afterwards and it reports No serve config. Reset replaces the whole serve config with an empty one, and Funnel listeners live in that same config, so they are cleared too. Re-add any public listener afterwards.
What breaks, with the strings you will see
Everything works until you log out. You ran the foreground form. The handler was tied to your CLI session and went away with it. Re-run the same command with --bg.
The command prints a URL and waits. HTTPS certificates are not enabled on the tailnet. The CLI is waiting for you to approve at the URL it printed. Approve, and it prints Success. and continues.
The app loads, then every link 404s. The mount path is being stripped. Either configure the app to live at its root, or put the prefix on the target URL as in the Grafana example.
cannot serve web; already serving TCP. The port you passed to --https is already a TCP forwarder. Pick another port, or turn the TCP handler off first.
Error: the CLI for serve and funnel has changed. You pasted a pre-1.52 command. The CLI prints the translated command on the next line. Copy that.
The URL times out from one device but works from another. Serve is listening, but the tailnet policy does not allow that peer to reach the node on that port. Confirm the node appears in tailscale status on the client, then check the ACL for the port.
One pointer and nothing more: tailscale funnel takes the same flags and targets, and publishes the result to the public internet instead of the tailnet. The same port cannot be both at once.
FAQ
Does tailscale serve survive a reboot?
Only with --bg. A background handler is written into the node's persistent serve config and comes back after a reboot, and after tailscale down followed by tailscale up. A foreground handler is stored under the CLI's watch session and is removed the moment that session ends, whether by Ctrl+C or by a closed SSH window.
Can I use tailscale serve without enabling HTTPS certificates?
Yes, in two modes. --http=<port> serves plain HTTP inside the tailnet, and --tcp=<port> forwards raw TCP. Neither asks for a certificate. Only the default HTTPS mode needs MagicDNS and HTTPS certificates enabled on the DNS page of the admin console, and if they are not, the CLI prints an approval URL and waits rather than failing.
Why does my app return 404 for every path under --set-path?
Serve strips the mount path before forwarding. A request for /api/users reaches the backend as /users. An app configured to live under /api never sees its prefix and answers 404. Fix it by putting the prefix on the target as well, --set-path=/api http://127.0.0.1:8080/api, so the proxy joins it back on after stripping.
How do I remove a single serve handler without clearing the rest?
Repeat the flags that created it and append off, for example sudo tailscale serve --https=443 --set-path=/api off. Run sudo tailscale serve status first to see the exact port and path. sudo tailscale serve reset clears every handler on the node instead, including Funnel.