Lizardtail is a Tailscale Serve wrapper around other commands. It runs a command, watches its output for a localhost server port, exposes that port with Tailscale Serve, and prints the private tailnet URL. Pass --public to use Tailscale Funnel intentionally.
lizardtail pnpm devLocal: http://localhost:5173
lizardtail: detected local server on http://127.0.0.1:5173
lizardtail: serving via Tailscale: https://my-host.tailabc.ts.net:8443
Use it when your dev server is running on a remote machine and you want to open it from another device on your tailnet without manually copying ports or reconfiguring Tailscale Serve.
- Runs any command you pass it, such as
pnpm dev,npm run dev,bun run dev, orpython -m http.server. - Streams the child command's stdout/stderr normally.
- Detects common dev-server output formats, including
http://localhost:5173,http://127.0.0.1:3000,started server on 0.0.0.0:8080, andPORT=4321. - Ignores timing output like
ready in 500 msso it does not accidentally expose port500. - Waits briefly after the first candidate port so multi-process commands, such as Laravel plus Vite, can print the better app-server URL.
- Waits for the detected port to accept local connections before exposing it.
- Runs
tailscale serve --bg --https <tailscale-port> http://127.0.0.1:<port>. - Prints the HTTPS MagicDNS URL for the current Tailscale device.
- Supports an explicit
--portwhen automatic detection is not possible. - Supports
--tailscale-portwhen you want the MagicDNS URL to include a specific HTTPS port. - Detects Laravel + Vite dev output, exposes both servers, rewrites Laravel's
public/hotfile to the Tailscale Vite URL, and proxies Vite assets with CORS headers so module scripts can load cross-origin. - Uses a stable alternate Tailscale HTTPS port by default: first free port from
8443upward. - Stays private to your tailnet by default.
- Supports
--public/--funnelfor intentional public internet sharing through Tailscale Funnel. - Cleans up the Tailscale Serve/Funnel mappings it created when the child command exits or you press
Ctrl+C. - Has editable blocked-port guardrails with default entries for common HTTP/HTTPS ingress ports.
-
Node.js 20 or newer.
-
Tailscale installed and available as
tailscaleonPATH. -
The device must be logged into Tailscale.
-
Tailscale Serve must be available for the device/tailnet.
-
For
--public, Tailscale Funnel must be enabled for the device/tailnet. -
Your user must be allowed to update Tailscale Serve/Funnel config. If
tailscale serveortailscale funnelsays access is denied, run this once:sudo tailscale set --operator=$USER
Check Tailscale before using lizardtail:
tailscale status
tailscale serve --help
# Optional, only for --public:
tailscale funnel --helplizardtail exposes services to your private tailnet via Tailscale Serve by default. It only uses Tailscale Funnel, which publishes to the public internet, when you explicitly pass --public or --funnel.
Install from npm:
npm install -g lizardtailThen run:
lizardtail helpUse git clone only if you want to work on lizardtail itself:
git clone https://github.com/dasomji/lizardtail.git
cd lizardtail
npm install
npm run build
npm linkYou can run the TypeScript source directly:
npm run dev -- pnpm devOr build and run the compiled CLI:
npm run build
node dist/index.js pnpm devlizardtail [options] -- <command> [args...]
lizardtail [options] <command> [args...]
lizardtail help [topic]
lizardtail config initUse -- when the command itself has flags that could be confused for lizardtail options:
lizardtail -- npm run dev -- --host 0.0.0.0lizardtail help
lizardtail help configThese commands are meant for both humans and coding agents: they describe usage, safety behavior, public/private exposure, and config file shape without needing to open the README.
| Option | Default | Description |
|---|---|---|
--port <port> |
auto-detect | Expose this port instead of reading one from command output. |
--host <host> |
127.0.0.1 |
Local host to pass to Tailscale Serve/Funnel. |
--timeout <ms> |
30000 |
How long to wait for a port to appear in command output. |
--tailscale-port <port> |
first free 8443+ |
Expose the main app on this Tailscale HTTPS port and print it in the MagicDNS URL. Alias: --https-port. |
--vite-tailscale-port <port> |
first free 8443+ |
Expose a detected Laravel Vite asset server on this Tailscale HTTPS port. Alias: --vite-https-port. |
--public, --funnel |
disabled | Use Tailscale Funnel for public internet access instead of private tailnet-only Serve. |
--no-open-check |
enabled | Skip waiting for the local port to accept connections before calling Tailscale. |
-h, --help |
Show help. |
lizardtail pnpm devIf the frontend dev server proxies API requests to a backend, expose the frontend port so the proxied backend remains reachable from the remote browser:
lizardtail --port 5173 npm run devIf Vite is configured to bind to another host:
lizardtail --host localhost pnpm devlizardtail -- npm run dev -- --host 0.0.0.0lizardtail --port 3000 npm run devBy default, lizardtail uses the first free Tailscale HTTPS port from 8443 upward, so multiple projects can be served at the same time:
https://my-host.tailabc.ts.net:8443
You can also choose the Tailscale HTTPS port explicitly:
lizardtail --tailscale-port 8450 pnpm devThat prints a URL like:
https://my-host.tailabc.ts.net:8450
Laravel development commands often start both the PHP app server and the Vite asset server. When lizardtail sees both, it:
- exposes the Laravel app server;
- starts a small local proxy in front of Vite that adds CORS headers;
- exposes that Vite proxy on a separate Tailscale HTTPS port;
- writes
public/hotto the Tailscale Vite URL so Laravel renders assets from the reachable Vite server.
lizardtail composer run devYou can choose the Vite Tailscale port explicitly:
lizardtail --vite-tailscale-port 8453 composer run devlizardtail also sets __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS for the child command when it can read your Tailscale MagicDNS name. The local proxy handles CORS for module scripts loaded from the Vite Tailscale URL.
If your app server lands on a known port, pass it with --port. Lizard Tail treats that as the main app port, but still watches briefly for companion services such as Vite and exposes them when detected:
lizardtail --port 8001 composer run devIf no companion service appears during the short settle window, it falls back to exposing only the explicit port.
By default, URLs are only reachable from devices in your tailnet. To intentionally publish through Tailscale Funnel:
lizardtail --public pnpm devor:
lizardtail --funnel pnpm devThis prints a public HTTPS URL such as:
https://my-host.tailabc.ts.net:8443
Use this only for apps you are comfortable exposing publicly. Stop lizardtail with Ctrl+C to remove the Funnel mapping it created.
Lizard Tail ships with a small default blocked-port list for common HTTP/HTTPS ingress ports:
{
"blockedPorts": [
{
"port": 80,
"scope": "both",
"reason": "Common HTTP ingress/proxy port. Blocking prevents dev exposure from replacing a production web route."
},
{
"port": 443,
"scope": "both",
"reason": "Common HTTPS ingress/proxy port. Lizard Tail defaults to high explicit Tailscale HTTPS ports instead."
}
]
}Create an editable config file:
lizardtail config initLizard Tail searches the current working directory for:
lizardtail.config.json.lizardtail.json
You can also set LIZARDTAIL_CONFIG=/path/to/config.json.
Each blocked-port entry has:
port: number from1to65535scope:"local","tailscale", or"both"— defaults to"both"reason: explanation shown when the rule blocks an action
If a config file exists, its blockedPorts list replaces the built-in default list. Keep, edit, or remove entries based on your own host.
lizardtail --timeout 60000 pnpm dev-
lizardtailstarts the command you provide. -
It streams the command output to your terminal.
-
It scans recent output for local ports and known multi-service setups.
-
With
--port, it treats that port as the known main app port but still waits briefly for companion-service output before falling back to a single-port exposure. -
Once it chooses what to expose, it waits for
127.0.0.1:<port>or the configured--hostto accept connections. -
It chooses the first free Tailscale HTTPS port from
8443upward, unless--tailscale-portwas provided. Ports in the configured blocked-port list are refused or skipped. -
It runs Tailscale Serve for private tailnet-only access:
tailscale serve --bg --https <tailscale-port> http://<host>:<port>
With
--public/--funnel, it runs Tailscale Funnel for public internet access:tailscale funnel --bg --https <tailscale-port> http://<host>:<port>
On older Tailscale versions, if that form fails for
127.0.0.1/localhost, it falls back to the same command with just<port>as the target. -
It reads
tailscale status --json, extracts the current device's MagicDNS name, and prints:https://<device-name>.<tailnet>.ts.net:<tailscale-port>
When the child command exits, or when you press Ctrl+C, lizardtail removes the Tailscale mappings it created for that run:
tailscale serve --https=<port> off
# or, with --public:
tailscale funnel --https=<port> offFor Laravel + Vite, it also closes the local Vite proxy and restores public/hot to the exact content seen before Lizard Tail rewrote it. If there was no previous public/hot, it removes the generated file. If another process changes the file after Lizard Tail writes it, Lizard Tail leaves that newer content in place and warns instead of clobbering it.
It only tracks ports and files created or modified by the current lizardtail process.
If the server does not print a recognizable port, pass it explicitly:
lizardtail --port 5173 pnpm devVerify Tailscale is running and logged in:
tailscale statusThen check Serve support and current mappings:
tailscale serve --help
tailscale serve statusSome Tailscale installs only allow root, or the configured Tailscale operator, to change Serve config. If you see:
Access denied: serve config denied
Use 'sudo tailscale serve ...'
To not require root, use 'sudo tailscale set --operator=$USER' once.
run:
sudo tailscale set --operator=$USERThen rerun lizardtail. This is a one-time local machine setup step.
Some frameworks, especially full-stack apps with separate backend and Vite dev servers, need more than one port exposed. lizardtail detects Laravel + Vite and exposes both automatically. For other multi-port setups, run a second lizardtail --port <port> ... command or configure Tailscale Serve manually with explicit high ports.
Some dev servers reject requests from the Tailscale hostname. Configure your dev server to allow the Tailscale MagicDNS host or to bind with the right host/CORS options. For example, Vite may need --host 0.0.0.0 and framework-specific allowed-host settings.
npm install
npm run typecheck
npm test
npm run buildThe test suite uses Node's built-in test runner through tsx and includes:
- unit tests for argument parsing and port detection;
- an integration-style CLI test with a fake
tailscaleexecutable and a real temporary HTTP server.
Issues and pull requests are welcome. Please include tests for behavior changes and run:
npm testbefore opening a pull request.
MIT. See LICENSE.