PortOS uses a contiguous port allocation scheme to make it easy to understand which ports are in use and which are available.
- Contiguous Ranges: Each app should use a contiguous block of ports
- Labeled Ports: Define all ports in the top-level
PORTSobject inecosystem.config.cjs(mirrored — manually kept in sync — inserver/lib/ports.js, since the ESM server can'trequire()the CommonJS config); the per-process label map for PM2 processes lives inserver/services/apps.js. Infrastructure dependencies (such as PostgreSQL on 5561) are provisioned viascripts/setup-db.js/ Docker Compose rather than registered as PM2 processes inapps.js. The mirror carries every port literal, including both PostgreSQL ports; the config's mode-dependentPOSTGRES(resolved fromPGMODEat load time) is exposed in the mirror asresolvePostgresPort(pgMode)over thePOSTGRES_NATIVE/POSTGRES_DOCKERliterals, soserver/lib/ports.jsstays free of filesystem reads.server/lib/ports.test.jsfails if the two drift apart - No Gaps: Avoid leaving gaps between port allocations within an app
Common port labels:
api- REST API serverapi-local- Loopback-only HTTP mirror of an API that is served over HTTPSui- Web UI / frontenddevUi- Vite dev server for a UI that production serves from its API processcdp- Chrome DevTools Protocolhealth- Health check endpointws- WebSocket server
| Port | Process | Label | Description |
|---|---|---|---|
| 5553 | portos-server | api-local | Loopback-only HTTP mirror of the API (only listens when HTTPS is active on 5555). Lets http://localhost:5553 work without cert warnings. Override with PORTOS_HTTP_PORT. |
| 5554 | portos-ui | devUi | Vite dev server (React UI) — only present in npm run dev; npm start serves the built client from :5555 directly. |
| 5555 | portos-server | api | Main API server — always the user-facing port. Switches between HTTP and HTTPS based on whether data/certs/{cert,key}.pem exists. |
| 5556 | portos-browser | cdp | Chrome DevTools Protocol |
| 5557 | portos-browser | health | Browser health check API |
| 5558 | portos-cos | api | CoS Agent Runner (isolated process) |
| 5559 | portos-autofixer | api | Autofixer daemon API |
| 5560 | portos-autofixer-ui | ui | Autofixer web UI |
| 5561 | portos-db (Docker container) | - | Infrastructure dependency: PostgreSQL Docker container provisioned by scripts/setup-db.js / Docker Compose (not a PM2 process in server/services/apps.js; native mode uses system pg on 5432). |
| 5562 | portos-whisper | whisper-server | Loopback whisper.cpp speech-to-text server. |
| 5563 | portos-server | eidoverse-host | Optional HTTPS/WebSocket bridge for the embedded Eidoverse Worlds page. Starts on demand and forwards to the managed app on loopback :8940. |
| 5568 | portos-llama-server | - | Loopback llama.cpp speculative-decoding server. Optional PM2 process, started/stopped from Models → LLMs. |
| 8000 | portos-mtplx | - | Loopback MTPLX OpenAI-compatible API (upstream's own default, kept so the shipped provider presets match). Optional PM2 process, started/stopped from Models → LLMs. See features/mtplx.md. |
| 18020 | vLLM (Docker) | - | Loopback vLLM Qwen3.8-27B / DFlash 2 container on an RTX 3090 host. Operator-started (docker compose --profile single up -d) — PortOS never brings it up on boot. See features/qwen38-rtx3090.md. |
| 18021 | SGLang (Docker) | - | Loopback SGLang Qwen3.8-27B container on a Hopper/Blackwell host. Operator-started (docker compose up -d) — PortOS never brings it up on boot. See features/sglang-qwen38.md. |
These three ports are easy to confuse, so:
┌─ :5555 ─ HTTPS app (Tailscale cert) ← always user-facing
remote browser ──────────┤
└─ :5555 ─ HTTP app (no cert) ← always user-facing
local scripts / curl ────── :5553 ─ HTTP loopback mirror (HTTPS mode only, 127.0.0.1)
vite dev (npm run dev) ──── :5554 ─ Vite dev server (dev only, separate process)
Rules of thumb:
:5555is the only port a remote user ever needs. The scheme (HTTP vs HTTPS) flips based on whether a TLS cert is provisioned (npm run setup:cert); the port number does not.:5553is a convenience for local terminals. When HTTPS is on,https://localhost:5555would trip a cert warning (the cert covers<machine>.<tailnet>.ts.net, notlocalhost). The loopback HTTP mirror on:5553lets curl/scripts skip TLS entirely. It binds to127.0.0.1only — never reachable over the network.:5554isvite devonly. Innpm run dev, Vite serves the React UI from:5554and proxies/api,/dataand/socket.ioto:5555. Innpm start(production), the React build is served from:5555itself;:5554is unused.- A server-owned path must never be answered by an SPA fallback — and there are two of them. Neither 404s. In dev, Vite answers an unproxied path with
index.htmland a200; in production the fallback inserver/index.jsskips a request only when its path carries a file extension, so an EXTENSIONLESS one falls through the same way. Either shape hands a binary loader HTML, which fails far from the cause (a missing/data/image-to-3dproxy entry surfaced asUnexpected token '<' … is not valid JSONfrom the GLB viewer, which took its whole route down), or hands an API client HTML with a success status. - Dev side:
/datais proxied as one wildcard prefix, so a new mount is covered the moment it is added. - Production side:
server/lib/assetRoutePrefixes.jslists the namespaces the server owns (SERVER_OWNED_PREFIXES) alongside the exact client routes inside them (spaPaths—/dataitself is the Data Manager page), andmountAssetRoutescloses each one with a terminating 404 (#4688). scripts/dev-proxy-drift.test.jsfails if the proxy, the mounts, and the client's own routes drift apart. It reads bothNAV_COMMANDSandApp.jsx's nested<Route>tree, so a new page under a server-owned prefix — which the terminator would otherwise 404 silently — fails the build even when only its:iddetail route exists.
- A server-owned path must never be answered by an SPA fallback — and there are two of them. Neither 404s. In dev, Vite answers an unproxied path with
Define all ports in a top-level PORTS object as the single source of truth:
// =============================================================================
// Port Configuration - All ports defined here as single source of truth
// =============================================================================
const PORTS = {
API: 5570, // REST API server
UI: 5571, // Web UI
CDP: 5572 // Chrome DevTools Protocol
};
module.exports = {
PORTS, // Export for other configs to reference
apps: [
{
name: 'my-api',
script: 'server.js',
env: {
PORT: PORTS.API
}
},
{
name: 'my-ui',
script: 'node_modules/.bin/vite',
args: `--port ${PORTS.UI}`,
env: {
VITE_PORT: PORTS.UI
}
}
]
};- Single Source of Truth: Each port defined once
- Importable: Other configs can
require('./ecosystem.config.cjs').PORTS - Clear Comments: Document what each port is for
- DRY: No duplication between
portsobject andenvvars
PortOS automatically detects ports from env vars:
PORT→ labeled asapi(oruifor-ui/-clientprocesses,healthfor-browserprocesses with CDP)CDP_PORT→ labeled ascdpVITE_PORT→ labeled asui--portin args → labeled asui
- Never bind a managed app inside
5553-5569— that whole band belongs to PortOS and its extensions, whether or not a given port currently shows a listener. Managed apps start at5570. See the warning below for why a collision here does not announce itself. - Check Available Ports: Use PortOS apps list to see which ports are in use
- Pick a Contiguous Range: Choose a starting port and allocate contiguously
- Define PORTS Object: Always define ports in a top-level
PORTSconstant - Avoid Common Ports: Stay away from well-known ports (80, 443, 3000, 8080, etc.)
| Range | Purpose |
|---|---|
| 5553-5561 | PortOS core services (includes the :5553 loopback mirror and the portos-db Docker container on :5561) |
| 5562-5569 | Reserved for PortOS extensions. Assigned: 5562 whisper, 5563 Eidoverse bridge (on demand), 5568 llama-server. Unassigned but still reserved: 5564-5567, 5569 |
| 5570-5599 | User applications — put managed apps here |
A collision inside
5553-5569is silent, not loud. The natural assumption is that a second listener on a taken port fails withEADDRINUSE, so an accidental overlap would announce itself. It does not. A wildcard bind (0.0.0.0) does not collide with an existing address-specific bind (127.0.0.1, a Tailscale address) — macOS and the BSDs accept both, and the specific bind then wins every connection. Two processes each believe they own the port; one of them quietly receives nothing.This is not hypothetical. A managed app bound
127.0.0.1:5563and the Tailscale address explicitly, while PortOS's Eidoverse bridge bound the wildcard. Both started without an error, PortOS logged🌐 Eidoverse host listening, and the Eidoverse page served the other app's admin UI. Nothing in either process reported a problem.The on-demand ports (5563, 5568) are the easiest to get wrong, because they are free at boot and only bind once a user opens the relevant page — so a port scan taken at install time shows them available. Treat the whole band as taken regardless.
server/services/eidoverseHost.jsnow probes127.0.0.1:<port>before binding and fails withEIDOVERSE_HOST_PORT_CONFLICT(409) rather than serving into the void. That guard covers the Eidoverse bridge only — it is not a general defence, which is why the range rule above still matters.
PostgreSQL in native mode listens on the system default :5432, outside these ranges. Two third-party
local runtimes also sit outside them, each on its own upstream default so an unmodified install works
against PortOS without editing anything: MTPLX on :8000 and the vLLM Qwen3.8-27B container on
:18020 (syv-ai/qwen38-27b-rtx3090's own compose file). The SGLang Qwen3.8-27B container sits
next to it on :18021 — that port is PortOS's own choice rather than an upstream default, since
SGLang publishes an image but no compose project (its own default is :30000).
The PortOS apps list (returned by GET /api/apps) shows registered PM2 processes and their mapped ports:
- Single port:
process-name:5555 - Multiple ports:
process-name (cdp:5556,health:5557)
Note that GET /api/apps only returns PM2 processes defined in server/services/apps.js (portos-server, portos-cos, portos-ui, portos-autofixer, portos-autofixer-ui, portos-browser). Infrastructure dependencies like portos-db (Docker container on port 5561, managed via scripts/setup-db.js / Docker Compose) are not PM2 processes and do not appear in GET /api/apps.
Use the API to get detailed port information for registered PM2 processes:
curl http://localhost:5555/api/apps | jq '.[].processes'