Port management
How LPM CLI handles port assignment, conflict detection, and cross-service env injection for dev orchestration.
When lpm dev runs a multi-service project, it needs to know which port each service listens on, detect conflicts before starting, remap busy ports without mutating lpm.json, and tell each service how to reach its peers. This page covers what LPM CLI does behind the scenes — declared-port checks, persisted override remapping, cross-service env injection, and the readiness model.
The CLI surface is lpm ports. The configuration field is lpm.json > services.
Port declaration
Services declare their port in lpm.json:
{
"services": {
"db": { "command": "docker compose up postgres", "readyPort": 5432 },
"api": { "command": "node server.js", "port": 4000, "dependsOn": ["db"] },
"web": { "command": "next dev", "port": 3000, "primary": true }
}
}Two related fields:
| Field | Purpose |
|---|---|
port | Preferred listener port. The primary and host-routed services get a managed port when this is omitted. |
readyPort | TCP port to poll for readiness. Defaults to port when absent. |
The split exists for services that listen on a different port than they expose for readiness — uncommon, but real (e.g., Postgres listens on 5432 and that's also its readiness probe; a service behind a sidecar might split them).
Conflict detection
Before starting any service, lpm dev walks every declared port and checks whether it's free:
TcpListener::bind(("127.0.0.1", port))If the bind fails, LPM CLI reports who's holding the port. Availability probes both IPv4 and IPv6 loopback, so an ::1 listener is not treated as a free port. Linux reads /proc, macOS / BSD use lsof and ps, and Windows uses the native IP Helper API plus documented process APIs. LPM CLI does not kill that process, but it also doesn't stop there: it picks the next available port, persists that remap for the current project in ~/.lpm/ports.toml, and continues startup with the reassigned value.
That means a declared web.port = 3000 can temporarily become 3001 on a machine where :3000 is already occupied, without editing lpm.json. LPM CLI injects the final value as PORT and passes framework-native arguments where required: Vite gets --port <N> --strictPort; Next.js, Nuxt, SvelteKit, Remix, and Astro get --port <N>. Multi-service managed endpoints coordinate through per-port leases, so concurrent orchestrators do not select the same replacement.
The primary service receives a managed port even without port or host. This gives browser-open, HTTPS, LAN forwarding, local-domain routes, the dashboard, and tunnels one final endpoint instead of a localhost:3000 guess. To reclaim an original port later:
lpm ports kill 3000 # kill the process holding the port
lpm ports inspect 3000 # inspect PID, command, cwd, project, and framework
lpm ports reset # forget this project's persisted override
lpm dev # retrylpm ports kill <port> includes a TOCTOU mitigation — after finding the PID, it re-checks that the same PID still owns the same port before sending SIGTERM. If the owner changed in the 50ms gap, the kill is aborted. Defends against PID-reuse races.
For broader cleanup, lpm ports kill <start>-<end> snapshots listeners in the inclusive range, dedupes PIDs, prompts for confirmation unless --yes is passed, then re-checks ownership before terminating. PID kills are explicit: use lpm ports kill --pid <pid> so a bare number always means a port.
lpm ports list
lpm ports list
lpm ports # default actionReads lpm.json > services, then for each declared port runs the same TcpListener::bind check as the conflict detector. Prints one row per service with the declared port, a ready or listening status dot, and (when listening) the owning PID + process name. Host-only services appear after lpm dev has persisted their assigned port. Services with only readyPort and no declared port or assigned host port are omitted.
Important: list shows the declared lpm.json ports, not the current persisted override map. If lpm dev previously remapped web from 3000 to 3001, lpm ports list still reports the declared 3000 row and whether :3000 is currently free or occupied.
If the current directory has no lpm.json services, lpm ports falls back to visible listening processes whose cwd is under the current project directory. Use lpm ports all or lpm ports --all for a system-wide table of listening TCP ports. On Windows, generic process cwd is not available through a stable public API, so system-wide rows show PID/process/image path/uptime but leave cwd/project/framework blank unless future LPM CLI-owned process tracking can provide them.
lpm ports reset
lpm ports resetClears persisted port overrides for the current project. LPM CLI keeps per-project port overrides in ~/.lpm/ports.toml (keyed by a hash of the project directory), so a service whose default port collides on this machine can be remapped without editing lpm.json. reset removes only this project's entry from that file — other projects' overrides are untouched.
Useful for the "I have no idea why my dev server won't start on the port I expected" escape hatch. Doesn't kill any processes; doesn't touch lpm.json. After reset, the next lpm dev falls back to the declared ports.
Cross-service env injection
When lpm dev starts a multi-service project, each service gets env vars exposing its peers' addresses:
For service "web":
DB_PORT=5432
DB_URL=http://localhost:5432
API_PORT=4000
API_URL=http://localhost:4000
For service "api":
DB_PORT=5432
DB_URL=http://localhost:5432
WEB_PORT=3000
WEB_URL=http://localhost:3000Pattern: {SERVICE_NAME_UPPERCASED}_PORT and {SERVICE_NAME_UPPERCASED}_URL. Hyphens in service names become underscores (a service named my-api injects MY_API_PORT / MY_API_URL). A service never receives its own _PORT/_URL — only peers do. These internal URLs remain http://localhost:<port> when lpm dev --https is active: LPM CLI terminates browser-facing TLS at its own frontend while service-to-service traffic keeps using the verified child HTTP ports.
Plus per-service overrides from lpm.json > services.<name>.env:
{
"services": {
"api": {
"command": "node server.js",
"port": 4000,
"dependsOn": ["db"],
"env": { "DATABASE_URL": "postgres://localhost:5432/myapp" }
}
}
}api gets DATABASE_URL plus the auto-injected DB_PORT/DB_URL/WEB_PORT/WEB_URL.
Readiness checks
A managed service is ready only after LPM CLI confirms that the launched process tree owns its assigned listener. This prevents an unrelated process that already happens to answer on the same port from satisfying readiness.
| Mechanism | Field | What it checks |
|---|---|---|
| Owned TCP | managed port | The assigned port is reachable and belongs to the launched service |
| TCP poll | readyPort | An additional connection to localhost:<port> succeeds |
| HTTP poll | readyUrl | An additional GET against the URL returns a 2xx |
Child stdout and stderr are also inspected for loopback URLs such as Vite's Local: http://localhost:5173/. The advertised scheme and base path are preserved, but the listener still has to be new, reachable, and owned by the launched process tree. Checks run up to readyTimeout (default 30s). Once ready, the next service in the topology can start.
For a managed service, readyUrl or a distinct readyPort is an additional application-level check after listener ownership. A service with no managed port and no explicit readiness field is treated as ready after a short launch grace period — useful for fire-and-forget workers and log collectors.
dependsOn ordering
lpm dev runs services in topological order — a service with dependsOn: ["db"] waits for db to report ready before launching. The graph is computed at startup; cycles are an error.
Independent branches start in parallel. If web and api both depend on db (but not on each other), they start concurrently as soon as db is ready.
What port is "the dev port"?
The service marked primary: true is the one whose verified endpoint:
- Receives the LPM CLI-owned HTTPS / tunnel / network frontends
- Gets the browser-open
- Has its URL printed prominently in the banner
- Is published as the active dev endpoint for bare
lpm tunnel
A one-service configuration is implicitly primary. With multiple services, mark exactly one as primary; --port, --https, --network, and --tunnel fail clearly when no unique primary exists.
Auto-restart
{ "services": { "worker": { "command": "node worker.js", "restart": true } } }restart: true enables auto-restart on crash with exponential backoff. Useful for crash-on-error workers during dev.
The restart loop respects dependsOn — if a downstream service crashes and a dep is no longer ready (e.g., the db went down too), the downstream waits for the dep to come back before restarting.
See also
lpm ports— CLI command referencelpm dev— what consumes the service configlpm.jsonservices — full field reference