Port management
How LPM 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 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 | Where the service listens. Used for env injection, display, and the readiness fallback. |
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 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 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. To reclaim the 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-owned process tracking can provide them.
lpm ports reset
lpm ports resetClears persisted port overrides for the current project. LPM 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. The URL scheme follows the dev server's HTTPS state: lpm dev --https swaps http://localhost:<port> for https://localhost:<port> in every injected URL. Pretty much every service in a typical lpm.json ends up with a usable URL for its peers without writing them out manually.
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 service is "ready" when one of these signals fires:
| Mechanism | Field | What it checks |
|---|---|---|
| TCP poll | readyPort (default = port) | A connection to localhost:<port> succeeds |
| HTTP poll | readyUrl | A GET against the URL returns a 2xx |
Polled every ~100ms up to readyTimeout (default 30s). Once ready, the next service in the topology can start.
If both readyPort and readyUrl are set, readyUrl wins. If neither is set and the service has no port, the service is treated as "ready immediately on launch" — useful for fire-and-forget services like 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 that:
- Receives
--https/--tunnel/--networkflags fromlpm dev - Gets the browser-open
- Has its URL printed prominently in the banner
Mark exactly one service as primary. If none is marked, lpm dev's flag-routing has no clear target and the browser-open is skipped.
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