All served local Tilt roles use Vercel Portless for stable, named URLs instead of port numbers. No service pins a port — portless assigns one and proxies the named URL to it.
- No port collisions — multiple projects can run simultaneously (no fighting over 3000/3001).
- Human-readable URLs —
api.stack.localhost:1355instead oflocalhost:3001. - Mirrors production — subdomain-based routing like real environments, so cross-service auth cookies behave the same locally.
Install portless globally:
npm install -g portlessThe portless proxy starts automatically on first use (port 1355). ./tilt_up.sh checks that portless is on PATH and refuses to boot without it.
All served roles use project-scoped names: <service>.stack
| Role | URL | Type |
|---|---|---|
Web (apps/web) |
http://web.stack.localhost:1355 |
Bun / Next.js process |
Blog (apps/blog) |
http://blog.stack.localhost:1355 |
Next.js (static / SSG) |
API (services/api) |
http://api.stack.localhost:1355 |
Bun process |
Payment (services/payment) |
http://payment.stack.localhost:1355 |
Bun process |
Storybook (libs/ui) |
http://storybook.stack.localhost:1355 |
Storybook dev server |
AI Worker (services/ai-worker) |
none — background load, no URL | plain local_resource |
The Tilt dashboard stays on http://localhost:10380 (not proxied through portless).
Portless routes are subdomains-with-a-port (web.stack.localhost:1355). Two things dislike that shape — both fixed by running the app directly on a pinned plain-localhost port:
-
HMR / WebSockets: portless doesn't proxy WebSocket connections, so Next.js hot-reload won't connect through the portless URL (it retries in the console — expected). Edit-refresh works; for live HMR, run
PORT=3000 bun --filter @stack/web dev. -
Google (and strict) OAuth: Google's loopback exception only accepts
localhost/127.0.0.1— not*.localhostsubdomains — soweb.stack.localhost:1355is rejected as an authorized redirect URI. To test Google/social sign-in locally, run web (and landing) on a pinned port instead:PORT=3000 bun --filter @stack/web dev # → http://localhost:3000 (bypasses portless) PORT=3002 bun --filter @stack/landing dev # → http://localhost:3002
Then point
BETTER_AUTH_URL+BETTER_AUTH_TRUSTED_ORIGINS/WEB_ORIGINathttp://localhost:3000, and registerhttp://localhost:3000/api/auth/callback/<provider>as an authorized redirect URI in the provider's console. (GitHub OAuth is lenient and works through the portless URL; Google is the strict one.)
Portless wraps the dev command, auto-assigns a random PORT, and proxies through port 1355:
portless api.stack bun --filter @stack/api dev
# Portless injects PORT=4xxx, proxies api.stack.localhost:1355 → localhost:4xxxThe service must respect process.env.PORT:
services/apiandservices/paymentuseNumber(process.env.PORT) || <fallback>in theirBun.servedefault export.apps/webrunsnext dev(no--port) — Next.js bindsPORTautomatically.
Storybook binds the port from its -p flag, so @stack/ui's script passes portless's injected PORT straight through:
"storybook": "storybook dev -p ${PORT:-6006} --no-open"portless storybook.stack bun --filter @stack/ui storybook then proxies storybook.stack.localhost:1355 → that port. Standalone (no portless) it falls back to 6006.
Containers that portless should proxy use a dynamic host port (set the port env var to 0 so Docker assigns one), detect it, and pass it via --app-port:
# 1. Start the container with its port var = 0 → Docker picks a random host port
# 2. Detect it
APP_PORT=$(docker compose port <svc> <container-port> | cut -d: -f2)
# 3. Proxy to it
portless <svc>.stack --app-port $APP_PORT docker compose logs -f <svc>Use env vars with defaults in docker-compose.yml so standalone usage still works:
adminer:
ports:
- "${ADMINER_PORT:-8090}:8080" # fixed standalone, dynamic under Tilt- Wrapped commands (all served roles): the route exists while the process runs. When Tilt stops, the process dies and the route auto-cleans.
- Portless proxy (port 1355): a shared daemon across all projects. Never stopped by Tilt — stop it manually with
portless proxy stop.
- Bun / Next process: wrap the serve_cmd with
portless <name>.stack <cmd>, and make sure the process readsprocess.env.PORT. - Docker container: use an env var for the port in
docker-compose.yml, detect it withdocker compose port, wrap withportless <name>.stack --app-port $PORT .... - No URL (background worker): plain
local_resource, no portless — mirrorai-worker. - Add the new URL to the table above. The name must be project-scoped:
<name>.stack.