# Port management (/docs/infra/port-management)



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`](/docs/infra/ports). The configuration field is [`lpm.json > services`](/docs/reference/lpm-json#servicesname).

## Port declaration [#port-declaration]

Services declare their port in `lpm.json`:

```json title="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 [#conflict-detection]

Before starting any service, `lpm dev` walks every declared port and checks whether it's free:

```rust
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:

```bash
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                    # retry
```

`lpm 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]

```bash
lpm ports list
lpm ports                    # default action
```

Reads `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-reset]

```bash
lpm ports reset
```

Clears 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 [#cross-service-env-injection]

When `lpm dev` starts a multi-service project, each service gets env vars exposing its peers' addresses:

```text
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:3000
```

Pattern: `{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`:

```json
{
  "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 [#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 [#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"? [#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 [#auto-restart]

```json
{ "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 [#see-also]

* [`lpm ports`](/docs/infra/ports) — CLI command reference
* [`lpm dev`](/docs/dev/dev) — what consumes the service config
* [`lpm.json` services](/docs/reference/lpm-json#servicesname) — full field reference
