One proxy, many apps, no port hunting. nsl gives every local service a stable name like myapp.localhost — and lets you mount sub-services under the same hostname via path prefixes like myapp:/api.
- $ npm run dev # "hm, was this 3000 or 5173 today?"+ $ nsl run npm run dev # http://myapp.localhost:3355A modern dev setup runs a handful of processes — web, API, DB admin, Storybook, maybe a worker. Port numbers are noise: they shuffle on every restart, they leak into bookmarks, and they break the moment a teammate's machine assigns something different. nsl fronts them all behind a single proxy port and routes by (host, path-prefix).
- One URL per service, forever —
web.localhost,api.localhost. Bookmark them, put them in docs, share them in Slack; the underlying port doesn't matter. - Path mounting —
nsl run --name web:/apipublishes the service asweb.localhost/api/*. Stack as many as you want under one hostname. - Longest-prefix wins — the proxy picks the most specific route, so
/api/internaloverrides/apioverrides/. - Layered config — system → user → project
nsl.toml→ env vars → flags. No CLI gymnastics for shared settings. - HTTPS on demand —
sudo nsl trustinstalls a local CA; per-hostname certs are minted lazily on the first SNI request. - Cross-platform — Linux (x64/arm64), macOS (x64/arm64), Windows (x64). Single prebuilt binary, no runtime deps.
Quick install (Linux/macOS) — downloads the right prebuilt binary for your
OS/arch from the latest GitHub release into /usr/local/bin:
curl -fsSL https://raw.githubusercontent.com/dotns/nsl/main/install.sh | sh
# or: wget -qO- https://raw.githubusercontent.com/dotns/nsl/main/install.sh | shInstall somewhere else, or pin a version (pass args after sh -s --):
curl -fsSL https://raw.githubusercontent.com/dotns/nsl/main/install.sh | sh -s -- --dir "$HOME/.local/bin"
curl -fsSL https://raw.githubusercontent.com/dotns/nsl/main/install.sh | sh -s -- --version v0.1.9Via npm (picks the right prebuilt binary for your OS/arch; works on Windows too):
npm i -g @dotns/nslFrom source:
cargo install --path .cd my-web-app
nsl run npm run dev
# -> http://my-web-app.localhost:3355No config, no flags. nsl:
- Infers the app name from
package.json, the Git root, or the cwd. - Starts the proxy daemon if it isn't already up.
- Reserves a port from
[app].port_range_start..port_range_end. - Registers the route and injects the allocated port into your command.
- Tails output until you Ctrl-C, then removes the route.
The default app pool is 20000..29999, which avoids common dev ports such as 3000, 5173, 8000, and 8080 while still leaving a broad auto-allocation range.
Set NSL=0 or NSL=skip to opt out of registration for a single invocation.
{
"scripts": {
"dev": "nsl run next dev"
}
}Commit that once and every contributor gets the same URL for the service.
nsl run [FLAGS] <CMD>... connects an application process to the proxy in this order:
- Loads configuration from system, user, project, environment, then CLI flags.
- Resolves the route name and optional path prefix from
--name,package.json, the Git root, or the cwd. - Starts the proxy daemon if it is not running. Auto-start uses
[proxy].listenandNSL_LISTEN;--listenis for explicitnsl startornsl reload. - Chooses the application port from
[app].port_range_start..port_range_end, unless--portpins it. The default pool is20000..29999. - Prepares the child process by exporting
PORT,HOST,NSL_URL, andNSL=1. - Rewrites
NSL_PORTplaceholders in child command arguments, then optionally adds framework-specific port flags. - Registers the route in
routes.json, including the path prefix,--strip, and--change-originoptions. - Starts the child command, waits for the app port to accept connections, and prints the stable URL.
- Streams output until the child exits or you press Ctrl-C, then removes the route.
nsl route is the manual path for services that are already running. It skips child process management and only writes or removes a route.
When --name is omitted, nsl run derives the route name from the current directory context:
- The nearest
package.jsonnamefield, walking up from the cwd. Scoped package names drop the scope:@scope/shopbecomesshop. - The Git repository root directory name.
- The current directory name, or
appif the directory has no usable name.
The chosen value is sanitized into a valid hostname label. In a Git multi-worktree checkout, non-default branches also prepend the sanitized last branch segment. For example, branch feature/login plus package shop becomes login-shop.localhost.
Use --name NAME when you need a stable name that does not depend on the directory, package metadata, or branch.
nsl run always exports these environment variables to the child process:
| Variable | Value |
|---|---|
PORT | Allocated app port |
HOST | 127.0.0.1 |
NSL_URL | Stable proxy URL |
NSL | 1 |
Most frameworks (Next.js, Express, Nuxt, Remix, Hono) already honor PORT.
For CLIs that expect explicit port flags, nsl can add framework-specific arguments when it recognizes the command:
| Command contains | Added arguments |
|---|---|
vite, react-router | --port <port> --strictPort --host 127.0.0.1 |
astro, ng, react-native | --port <port> --host 127.0.0.1 |
expo | --port <port> --host localhost |
wrangler + dev | --port <port> --ip 127.0.0.1 |
If the command already contains --port or --host, nsl leaves that option alone.
For unknown CLIs that do not read PORT, pass the allocated app port with the NSL_PORT argument placeholder:
nsl run ./server --port NSL_PORT
nsl run ./server --addr 127.0.0.1:NSL_PORT
nsl run ./server --listen=127.0.0.1:NSL_PORTnsl replaces NSL_PORT only in the child command arguments, after it allocates the app port.
The proxy routes each request by two keys: the hostname (minus any configured domain suffix) and the longest matching path prefix. That simple model gives you subdomain-per-service and path-mounted sub-services at the same time.
# One hostname, three services, three commands:
nsl run --name shop npm run web # shop:/ -> :5173
nsl run --name shop:/api npm run api # shop:/api/* -> :4000
nsl run --name shop:/docs npm run docs # shop:/docs/* -> :8000
nsl run --name shop:/api --strip npm run api # /api/users -> /users upstreamflowchart LR
B["Browser"]
P["proxy daemon :3355<br/>routes.json"]
A1["shop :5173"]
A2["api :4000"]
A3["docs :8000"]
B -- "shop.localhost/cart" --> P
B -- "shop.localhost/api/v1/users" --> P
B -- "shop.localhost/docs/intro" --> P
P -- "/" --> A1
P -- "/api/*" --> A2
P -- "/docs/*" --> A3
The matcher is greedy on the prefix:
| Request path | Matches route | Routed to |
|---|---|---|
/cart | shop:/ | :5173 |
/api | shop:/api | :4000 |
/api/v1/users | shop:/api | :4000 |
/api/internal/trace | shop:/api/internal | (most specific wins) |
/docs/intro | shop:/docs | :8000 |
--strip removes the matched prefix before forwarding (/api/users → /users). Handy when a backend doesn't know it lives under /api.
Register a route as shop.localhost and nsl will also serve it as shop.dev.local — as long as both suffixes are in [proxy].domains. Matching happens on the leading label, so one route works across every domain you list:
[proxy]
domains = ["localhost", "dev.local", "test"]For suffixes that don't auto-resolve like .localhost does, run sudo nsl hosts sync to drop entries into /etc/hosts (inside # nsl-start / # nsl-end markers), or point a local dnsmasq at 127.0.0.1.
For features that require a secure context (Service Workers, Secure cookies, crypto.subtle), terminate TLS at the proxy:
sudo nsl trust # install the local CA (once per machine)
nsl start --httpsThe CA is generated on first run and trusted on macOS (Keychain), Linux (update-ca-certificates / NSS), and Windows (certutil). Firefox keeps its own trust store — import the CA manually there. Per-hostname leaf certs are generated on demand from the first SNI handshake and cached under certs/.
nsl run [FLAGS] <CMD>... Launch a process behind a proxied route.
nsl serve [FLAGS] [DIR] Serve a static directory behind a proxied route.
nsl start [FLAGS] Start the proxy daemon.
nsl stop Stop the proxy daemon.
nsl reload Stop + start, re-reading config.
nsl logs [-n N] [--follow] Print daemon log.
nsl status Daemon state + routes + effective config.
nsl list Active routes only.
nsl route [NAME[:/PATH]] [PORT] Register/remove a static route.
nsl get <NAME[:/PATH]> Print the URL for a name (for CI / scripts).
nsl trust Install the local CA into the trust store.
nsl hosts sync | clean Sync route hostnames to /etc/hosts.
| Flag | Description |
|---|---|
-n, --name NAME[:/PATH] | Override the inferred name (and optional path prefix). |
-p, --port N | Pin the child to a fixed port. |
-s, --strip | Strip the matched prefix before forwarding. |
-c, --change-origin | Rewrite the outgoing Host header to the target address. |
-f, --force | Take over a route currently held by another process. |
nsl serve [DIR] serves a directory of static files behind a stable name —
no separate file server needed. It binds an in-process HTTP server (built on
the same engine as the proxy) to an allocated port, registers the route, and
serves DIR (default: the current directory) until you press Ctrl-C. The name
is inferred the same way as nsl run, or set it with --name.
nsl serve ./dist # -> http://dist.localhost:3355
nsl serve ./dist --spa # SPA: unmatched paths fall back to index.html
nsl serve ./files --list # browse folders with an HTML index
nsl serve --name docs:/guide ./site --strip # mount under /guide, strip the prefix--spa is for single-page apps: any path that doesn't match a file is served
index.html with a 200 status, so client-side routing works on deep links
and refreshes. --list turns on an HTML directory listing for folders that have
no index.html (off by default). Files are served with content-type detection
and HTTP range requests; path mounting and --strip behave exactly as they do
for nsl run. There is no --port: nsl serve is itself the server, so it
always allocates a port from [app].port_range — you only ever use the stable
.localhost URL.
| Flag | Description |
|---|---|
-n, --name NAME[:/PATH] | Override the inferred name (and optional path prefix). |
--spa | Serve index.html for unmatched paths (SPA routing). |
-l, --list | Show an HTML directory listing for index-less folders. |
-s, --strip | Strip the matched prefix before serving. |
-f, --force | Take over a route currently held by another process. |
| Flag | Description |
|---|---|
--listen ADDR | Override [proxy].listen (e.g. 127.0.0.1:3355 or :3355). |
--https | Terminate TLS at the proxy. |
--foreground | Stay in the current shell instead of daemonizing. |
Use NSL_LISTEN=ADDR when starting or reloading the proxy from scripts:
NSL_LISTEN=127.0.0.1:3355 nsl start
NSL_LISTEN=:3355 nsl reloadnsl logs reads the proxy daemon log at state_dir/proxy.log.
nsl logs
nsl logs -n 100
nsl logs --followApplication output from nsl run is streamed to the current terminal and is not persisted by nsl.
nsl route registers a route for something you didn't start through nsl — a Docker container, a compiled binary, a service on another host.
nsl route api 3001 # api:/ -> :3001
nsl route api:/v1 3001 --strip # strip /v1 before forwarding
nsl route api --removeNAME:/PATH mounts the target under a path prefix on the same hostname. Add
--strip when the upstream expects root-relative paths: /v1/users reaches the
target as /users.
| Flag | Description |
|---|---|
--remove | Remove the route. |
-f, --force | Replace an existing route. |
-s, --strip | Strip the matched path prefix before forwarding. |
-c, --change-origin | Rewrite the outgoing Host header to the target address. |
Reserved words:
run,serve,start,stop,reload,logs,route,get,list,status,trust,hosts. Usensl run --name <name> <cmd>if a reserved word collides with your project name.
Configuration has three scopes:
- Proxy scope (
[proxy]) controls the front proxy itself: where it listens, whether it terminates HTTPS, which domain suffixes it accepts, and how URLs are displayed. - Application scope (
[app]) controls hownsl runallocates ports for child processes. - State scope (
[paths]) controls where runtime state such as routes, logs, PID files, and certificates are stored.
Configuration is merged lowest → highest:
/etc/nsl/config.toml(system)~/.nsl/config.toml(user)- Nearest
./nsl.tomlwalking up from cwd (project) NSL_*environment variables- CLI flags
Full template in config.example.toml.
[proxy]
listen = "127.0.0.1:3355"https = falsedomains = ["localhost", "dev.local"]
# max_hops = 5 # loop-detection cap# Override URL display when an external reverse proxy fronts this domain.# (Affects `nsl get` / `nsl status` output only; doesn't change routing.)
[proxy.display."dev.example.com"]
https = true# port = 443
[app]
port_range_start = 20000port_range_end = 29999
[paths]
# state_dir = "/absolute/path/to/nsl-state"[proxy].listen configures the proxy's own listening socket. It is separate from the app port that nsl run allocates for the child process.
[proxy]
listen = "127.0.0.1:3355"# loopback only# listen = ":3355" # all IPv4 interfaceshttps = falsedomains = ["localhost", "dev.local"]Override it at proxy startup with either a flag or environment variable:
nsl start --listen 127.0.0.1:8080
NSL_LISTEN=:3355 nsl reload[proxy].domains controls which suffixes the proxy recognizes. .localhost usually resolves automatically. Other suffixes often need sudo nsl hosts sync or local DNS.
Domain display overrides affect generated URLs from nsl get and nsl status; they do not change route matching:
[proxy.display."dev.example.com"]
https = trueport = 443[app] controls the app port pool used by nsl run.
[app]
port_range_start = 20000port_range_end = 29999For each nsl run, the selected app port is passed to the child process through PORT, and can also be inserted into command arguments with the literal NSL_PORT placeholder. Use nsl run --port N only when the child process must use a fixed port.
| Variable | Purpose |
|---|---|
NSL_LISTEN | Proxy listen address (e.g. 127.0.0.1:3355 or :3355). |
NSL_HTTPS | 1 / true enables HTTPS. |
NSL_DOMAINS | Comma-separated allowed domain suffixes. |
NSL_STATE_DIR | Override the state directory. |
nsl run also exports PORT, HOST, NSL_URL, and NSL=1 into the child process.
| Scenario | Location |
|---|---|
| Non-privileged proxy port (Unix) | ~/.nsl |
| Privileged proxy port (Unix) | /tmp/nsl |
| Non-privileged proxy port (Windows) | %USERPROFILE%\.nsl |
| Privileged proxy port (Windows) | %LOCALAPPDATA%\nsl |
| Override | NSL_STATE_DIR=/abs/path |
Contents:
| File | Purpose |
|---|---|
routes.json | Persisted routes (shared between CLI + daemon). |
proxy.pid | Daemon PID. |
proxy.port | Port the daemon actually bound. |
proxy.log | Daemon stdout/stderr. |
certs/ | CA + per-hostname leaf certs. |
If a dev server upstream-proxies to another nsl-backed service, set changeOrigin: true on that proxy so the Host header matches the target. Otherwise the request lands back at the source app and the chain loops.
// vite.config.ts
server: {proxy: {"/api": {target: "http://api.localhost:3355",changeOrigin: true,ws: true},},}nsl tags every forwarded request with x-nsl-hops. Once it exceeds [proxy].max_hops (default 5) the proxy short-circuits with a branded 508 Loop Detected page — so you see the misconfiguration instead of a hung request.
Next.js 15+ adds a dev-time origin guard. Add your hostnames to
allowedDevOriginsinnext.config.js:module.exports={allowedDevOrigins: ["*.localhost","*.dev.local"],};
Vite / webpack — see Chaining nsl apps above.
Safari / any non-Chromium browser that doesn't auto-resolve
*.localhost— runsudo nsl hosts synconce per domain suffix, or use.test/.dev.localwith a local dnsmasq.
nsl stop
sudo nsl hosts clean # if you ever ran `nsl hosts sync`
rm -rf ~/.nsl # CA, routes, logs
sudo rm -rf /tmp/nsl # only if you ran the proxy on a privileged port
npm uninstall -g @dotns/nslIf you plan to use HTTPS again afterward, re-run sudo nsl trust — the old CA was wiped with the state directory.
- "proxy is not running" after
nsl route—nsl runauto-starts the proxy, butnsl routedoes not. Runnsl startonce. - Port already in use — something else holds
3355. Change withnsl start --listen 127.0.0.1:8080or[proxy].listen = "127.0.0.1:8080". .localhostdoesn't resolve on Linux — glibc resolves*.localhostby default, but a few minimal distros strip the rule. Either restore it in/etc/nsswitch.confor switch tosudo nsl hosts syncwith a custom suffix.- Browser says the HTTPS cert isn't trusted — run
sudo nsl trust. For Firefox on Linux, import the CA manually (Firefox uses its own NSS database). - WebSocket / HTTP/2 — transparently upgraded; no special flag.
The subdomain routing model is inspired by vercel-labs/portless. nsl rewrites it in Rust and extends it with path-prefix mounting, longest-prefix matching, cross-domain aliasing, and a TOML config hierarchy.
Apache-2.0. See LICENSE.