lizardtail
v0.1.0
Published
Run a dev server command, detect its port, expose it with Tailscale Serve or Funnel, and print the URL.
Maintainers
Readme
lizardtail
lizardtail 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:8443Use 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.
Features
- 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.
Requirements
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.
Installation
From source
git clone https://github.com/dasomji/lizardtail.git
cd lizardtail
npm install
npm run build
npm linkThen run:
lizardtail --helpDuring development
You 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 devUsage
lizardtail [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.0Help
lizardtail 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.
Options
| 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. |
Examples
Vite / frontend dev server
lizardtail pnpm devIf Vite is configured to bind to another host:
lizardtail --host localhost pnpm devnpm script with extra flags
lizardtail -- npm run dev -- --host 0.0.0.0Known port
lizardtail --port 3000 npm run devMagicDNS URL with an explicit port
By 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:8443You can also choose the Tailscale HTTPS port explicitly:
lizardtail --tailscale-port 8450 pnpm devThat prints a URL like:
https://my-host.tailabc.ts.net:8450Laravel / composer run dev
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 and you only want to expose that server, you can force it:
lizardtail --port 8001 composer run devPublic internet sharing
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:8443Use this only for apps you are comfortable exposing publicly. Stop lizardtail with Ctrl+C to remove the Funnel mapping it created.
Editable blocked ports
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.
Longer startup timeout
lizardtail --timeout 60000 pnpm devHow it works
lizardtailstarts the command you provide.It streams the command output to your terminal.
It scans recent output for a local port.
Once it finds a port, 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>
Shutdown behavior
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> offIt only tracks ports created by the current lizardtail process.
Troubleshooting
No port detected
If the server does not print a recognizable port, pass it explicitly:
lizardtail --port 5173 pnpm devTailscale command fails
Verify Tailscale is running and logged in:
tailscale statusThen check Serve support and current mappings:
tailscale serve --help
tailscale serve statusAccess denied: serve config denied
Some 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.
Browser cannot load assets
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.
Host checks or CORS failures
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.
Development
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.
Contributing
Issues and pull requests are welcome. Please include tests for behavior changes and run:
npm testbefore opening a pull request.
License
MIT. See LICENSE.
