Reserve and lease local TCP ports so parallel worktrees, agents, and dev
servers stop colliding. The binary is porta — one small daemonless Rust
executable.
Running one application locally is easy. Running several copies is not: every frontend, API, database, worker, and debugger wants a port, usually from the same familiar defaults. Multiply that by a few git worktrees and a coding agent or two, and something is always camped on 3000.
Port Authority is a small allocator for exactly that situation. Each worktree asks for the ports it needs and gets numbers nobody else has claimed. State lives in one per-user registry file, concurrent runs coordinate through an OS file lock, and nothing runs in the background.
Reservations are advisory. porta hands out numbers and remembers them, but it
can't stop an uncooperative process from binding a port after the command
exits. Most dev servers have a strict-port flag for that; the recipes below use
it.
Port allocation isn't a new problem, and porta might not be the right fit for you. If one of these suits your situation better, use it:
- get-port — a small Node.js library for "give me a free port right now," with preferred ports and ranges. Reach for it when application code needs a port inline rather than from a CLI.
- PortKeeper — a Python registry
with file locking that can also hold sockets open, write
.envfiles, and launch your command with the allocated values. Worth it if you want the allocator to wire up configuration too. - port-registry — a Go daemon with a REST API and SQLite, worktree-aware and built for agent workflows. Good when you want a continuously available local service and more than one kind of client.
Sometimes you need no tool at all. If your application can accept an
already-bound socket, binding port 0 and letting the OS pick is safer than
reserving a number and binding later. And lsof -i, ss -ltnp, or netstat
show what's listening right now — they just can't see a reservation for a
worktree that isn't currently running.
porta stays narrow: no daemon, ports that stay assigned to a directory while nothing is running, and cleanup that follows the worktree's lifecycle. The fuller comparison, including where these tools do better, is in docs/related-work.md.
Port Authority is at 0.9 — feature complete and stabilizing ahead of 1.0. Every channel below is live.
Works on macOS and Linux alike:
brew install happycodelucky/tap/portaInstall the released binary through the GitHub backend — no Rust toolchain needed:
mise use -g github:happycodelucky/portaOr build it from the crate:
mise use -g cargo:port-authority@latestOnce the shorthand lands in the mise registry, mise use -g porta@latest will
do the same thing.
Every release ships .deb packages for amd64 and arm64:
sudo apt install ./porta_0.9.1_amd64.debThere's no APT repository to add — download the .deb from the
releases page and install
the file directly. sudo apt remove porta uninstalls it.
cargo install port-authority --lockedThat compiles from source. To grab the released binary instead:
cargo binstall port-authorityThe crate is named port-authority because porta was already taken by an
unrelated project. The binary it installs is still porta.
Checksummed archives for macOS (Apple Silicon) and Linux (ARM64 and x86-64) are
attached to each release,
with a SHA256SUMS file to verify against. Extract porta onto your PATH.
Intel Macs don't get a prebuilt binary — build one with
cargo install port-authority --locked.
There's no native Windows build. Run porta inside WSL2 and follow the Linux instructions above — worktrees and dev servers hosted in WSL2 get the same coordination they would on Linux. Ports you bind in WSL2 are reachable from Windows, so this covers most of what you'd want it for.
Native Windows support isn't in v1. What it would take is written up in SPEC.md, if you want to make the case.
git clone https://github.com/happycodelucky/porta.gitmise install && mise run installmise install fetches the pinned toolchain; mise run install runs the full
check gate and installs porta into Cargo's binary directory, normally
~/.cargo/bin (CARGO_INSTALL_ROOT is respected). Without mise:
cargo install --path . --lockedThen confirm it landed:
porta --versionHomebrew and the Debian package install both. man porta covers the top level
and every subcommand has its own page, so man porta-lease and
man porta-listeners work too. Completions cover bash, zsh, and fish, and they
know the value lists for flags like --order.
Cargo can't install man pages, so cargo install and cargo binstall leave you
with just the binary. Every release archive carries the files under man/ and
completions/ if you'd rather place them yourself.
lease lease an open port without a directory
reserve reserve ports for a directory
get return a reserved port for a directory
list list directory reservations and leases
listeners show OS TCP listeners and owning processes
info show registry or socket information for ports
release release reserved ports or leases
clean sweep and clean the registry
config list, read, or update configuration
lease claims one open port for a limited time, no setup required:
$ porta lease --port 62000 --timeout 1h
62000The timeout defaults to two minutes. An expired lease is reclaimed by a later
command once its port is unbound; if you actually bound the port, the lease is
kept as expired_in_use and checked again later.
Give a lease a key and it becomes renewable — the same call returns the same port and resets the clock:
$ porta lease -k preview -t 2h
62001
$ porta lease -k preview -t 2h
62001On lease and reserve, --port/-p is a starting preference, not a demand:
the scan begins there and walks upward past anything live, reserved, or
already leased. A key's port is chosen once; a later --port won't move it.
reserve is the durable version. Allocations belong to a directory and stay
until you release them:
$ porta reserve --port 6006 --key web --key api --key database
web=6006 api=6008 database=6009
$ porta get --key web
6006Reserving is additive and idempotent: run it again with an extra key and the
existing keys keep their ports while only the new key allocates. Add
--contiguous/-c when new allocations must form one unbroken block, and
--expires/-e (a duration like 2d3h5m, or an RFC 3339 timestamp) when the
whole reservation should have a deadline. The directory argument defaults to
the current directory.
get returns one port: pass --key when the reservation has several.
Three read commands:
$ porta list # what porta is tracking
$ porta listeners # what the OS says is actually listening
$ porta info 6006 # everything about specific ports, both sourceslisteners asks the operating system for live TCP listeners — port, address,
family, PID, and process name. Pass ports to filter; JSON output also reports
requested ports with no listener in missing_ports. OS permissions decide
which listeners and owners you can see.
Sort it with --order/-o — port (the default), address, family, pid,
or process. Grouping by process is handy when you're hunting down which app
is hogging things:
$ porta listeners -o process
PORT ADDRESS FAMILY PID PROCESS
5000 0.0.0.0 IPv4 631 ControlCenter
7000 0.0.0.0 IPv4 631 ControlCenter
5037 127.0.0.1 IPv4 69264 adblist groups allocations under their directory, with leases in their own
section:
$ porta list
/Users/paul/dev/app
web 6006 reserved in use
api 6008 reserved
leases
preview 62000 leased expires 07/22/2026 04:12:09 PM -07:00Expirations print in your local timezone. Scripts should use
porta list --json, which keeps timestamps as UTC RFC 3339.
info reports each requested port as reserved, leased, expired_in_use,
available, or in_use.
$ porta release # whole reservation for the current directory
$ porta release -k api # just one key
$ porta release -p 6006 -p 6008 # exact ports, regardless of owner
$ porta clean # sweep expirations, age out deleted worktreesReleasing by --port targets exact registry entries anywhere — it can't be
combined with --key or a directory, and it's atomic: if any requested port
isn't registered, nothing changes. Releasing never terminates the process
bound to a port.
Deleted worktrees aren't reaped on sight. A missing directory gets marked with
a timestamp — by list or clean, whichever notices first — and is only
reclaimed after a grace period (a week by default). If the worktree comes
back, the mark clears itself. Cleanup also runs automatically once you
accumulate enough reservations.
--force skips the wait and reaps every currently-missing directory in one
pass:
porta clean --forcereaped 2
released leases 0
expired reservations 0
marked missing 0
restored 0
next automatic cleanup 30
It only drops registry entries for directories that are gone right now — it never touches files, live reservations, or processes.
Every data command accepts --json/-j, before or after the subcommand. Each
response carries version: 1 and a type discriminator such as lease,
listeners, or config_get. Errors use type: "error" with a stable code,
and exit statuses match plain-text mode:
{"version":1,"type":"error","error":{"code":"invalid_port","message":"Port must be between 1 and 65535"}}These examples use Bash. Quote "$PWD" rather than backtick pwd so
directory-derived keys survive paths with spaces. Prefix the key with a
purpose like storybook: when more than one tool runs from the same
directory.
port="$(porta lease -p 6006 -k "$PWD")"
printf 'Using port %s\n' "$port"Returns the first available port at or above 6006. The directory is the
lease identity, so running it again from the same place renews the same
lease. This is a timed lease, not a reservation — no reserve needed, and
missing-directory cleanup never touches it.
storybook_port="$(porta lease -p 6006 -k "storybook:${PWD}" -t 8h)"
npm run storybook -- --port "$storybook_port" --exact-portThe purpose prefix keeps Storybook from sharing an identity with another
server in the same worktree. --exact-port makes Storybook fail loudly if
another process wins the advisory bind race instead of silently moving
elsewhere. See the
Storybook CLI options.
vite_port="$(porta lease -p 5173 -k "vite:${PWD}" -t 8h)"
npm run dev -- --port "$vite_port" --strictPortVite's --strictPort gives the same bind-race protection. See the
Vite CLI options.
api_port="$(porta lease -p 3000 -k "api:${PWD}" -t 8h)"
web_port="$(porta lease -p 3000 -k "web:${PWD}" -t 8h)"
export API_PORT="$api_port"
export WEB_PORT="$web_port"
npm run devThe two identities are independent and needn't be contiguous. Each scan
starts at 3000 and skips live sockets, reservations, and unexpired leases.
porta reserve -p 6006 -k storybook -k web -k api "$PWD"
storybook_port="$(porta get -k storybook "$PWD")"
web_port="$(porta get -k web "$PWD")"
api_port="$(porta get -k api "$PWD")"Use reserve when the names should hold steady across commands and terminal
sessions. Repeating it returns existing keys and allocates only new ones.
porta reserve -p 7000 -c -k api -k metrics -k debugger "$PWD"Finds the first three-port block at or above 7000 that's free in both the
registry and the operating system.
worker_id="${CI_NODE_INDEX:-0}"
test_port="$(porta lease -p 55000 -k "tests:${PWD}:${worker_id}" -t 30m)"
PORT="$test_port" npm testInclude the worker in the key so a retry renews its own lease without colliding with the other workers.
set -euo pipefail
port="$(
porta lease -p 6006 -k "preview:${PWD}" --json |
jq -er 'select(.version == 1 and .type == "lease") | .port'
)"jq -e fails the script when the version, type, or port doesn't match the
expected success shape.
porta info 6006
porta listeners 6006
porta release -p 6006info combines registry ownership with live availability; listeners names
the owning PID and process. release -p removes the registry entry — it
never kills the process on the port.
Per-user state lives in ~/.porta/ (override with PORTA_HOME):
~/.porta/registry.json
~/.porta/registry.lock
~/.porta/config.toml
~/.porta/config.lock
The registry is strict JSON guarded by an OS file lock. Every transaction acquires the lock, rereads and validates state, sweeps expirations, probes candidate ports while holding the probe sockets, atomically replaces the file, and only then lets go. Lock waits are bounded at five seconds. Version 1 registries migrate atomically on their next operation.
$ porta config
$ porta config get missing_for
$ porta config set default_port 56000Bare porta config prints every key with its effective value, default, and
whether it's explicitly set. --json gives the same entries with typed
values.
| Key | Default | Constraint |
|---|---|---|
default_port |
55000 |
1..=65535 |
lease_timeout |
2m |
positive duration |
missing_for |
7d |
non-negative duration |
cleanup_trigger_start |
30 |
1..=100 directories |
cleanup_trigger_step |
15 |
10..=15 |
Durations combine s, m, h, d, and w — 2d3h5m is two days, three
hours, five minutes. The config file is sparse: config set writes only the
keys you've set, and everything else keeps its default.
Everything runs through mise:
mise install
mise run check # fmt + clippy + tests + shellcheck
mise run smoke # end-to-end CLI test against isolated stateThe task graph also has format, lint-stable (Clippy on moving stable),
build, install, docs (man pages and completions), version, audit, and
package. Tests isolate their state with PORTA_HOME and include multi-process
registry and configuration concurrency coverage. mise.lock pins
resolved tools for the supported platforms.
Releases start with mise run version 1.2.3. The version lives in seven places
— including a test that asserts what porta --version prints — so bumping the
manifest by hand leaves the suite red. Then commit, push, and tag.
The full CLI, JSON, registry, and packaging contract lives in SPEC.md, not in this README.
Right now porta installs from a personal tap. Getting it into Homebrew's own
formula list — so brew install porta just works, with no tap to add — depends
on showing the maintainers that people actually use it.
So if porta saved you from one more EADDRINUSE, a
star helps it get there. Bug reports
and "here's how I use it" issues help more.
Released under the MIT License.
Port Authority is an independent open-source project, not connected to Akkio.