King of Flavors docs

Portless debugging

Post mortem and runbook for Portless clean URL failures on macOS and WSL.

Portless debugging

This page documents the Portless clean URL issue observed on macOS and WSL in July 2026. The short version is that https://web.localhost only works when the Portless proxy owns local HTTPS port 443. When another local service owns 443, Portless falls back to an unprivileged proxy port and the visible URLs become https://web.localhost:1355, https://api.localhost:1355, and https://docs.localhost:1355.

The WSL investigation found two interacting problems:

  • tailscaled was still running inside WSL and listening on the WSL Tailscale addresses at port 443.
  • Portless had persisted its previous fallback proxy port in ~/.portless/proxy.port, so later bun dev runs reused 1355 even though this repo's wrapper sets PORTLESS_PORT=443.
  • After tailscale serve reset, the Tailscale 443 listener disappeared, but the old Portless fallback proxy was still alive on *:1355 as PID 32598.

That means "disable all networking" is not the right long-term answer. The better fix is to remove or move the specific Tailscale Serve/Funnel listener that owns 443, then force Portless back onto 443.

Expected model

The repo's preferred command is:

bun dev

That runs scripts/portless-dev.ts, which checks for Node.js 24+, finds the checked-in portless dependency, and starts the Portless CLI with these environment values:

PORTLESS_PORT=443
PORTLESS_HTTPS=1

The intended public URLs are:

web:  https://web.localhost
api:  https://api.localhost
docs: https://docs.localhost

The upstream Portless docs describe this same model: HTTPS is enabled by default, Portless binds port 443, and package apps receive random backend ports while the proxy keeps browser URLs stable. Portless also documents PORTLESS_PORT as the proxy port override and ~/.portless/proxy.port as the persisted state file.

Useful upstream references:

Observed WSL failure

The control run showed Portless starting successfully, but on 1355:

portless

  api   https://api.localhost:1355
  docs  https://docs.localhost:1355
  web   https://web.localhost:1355

[api] API listening on http://localhost:4121
[web] Local: http://localhost:4554/
[docs] Local: http://localhost:4346

This is not an app routing failure. It is Portless publishing URLs with a non-default proxy port. The app kept working because the web and API helpers preserve the current Portless port:

  • apps/web/src/config/portless.ts maps web.localhost to api.localhost and preserves location.port.
  • apps/api/src/portless.ts maps api.localhost back to web.localhost and preserves alternate proxy ports for CORS and checkout return URLs.

That preservation is intentional. It keeps https://web.localhost:1355 functional, but it also makes the proxy fallback visible.

Local state evidence

The Portless state directory had already recorded the fallback port:

~/.portless/proxy.port -> 1355
~/.portless/proxy.tls  -> 1
~/.portless/proxy.pid  -> 32598

Routes were registered against the fallback proxy:

[
  {
    "hostname": "api.localhost",
    "port": 4121,
    "pid": 43487
  },
  {
    "hostname": "docs.localhost",
    "port": 4346,
    "pid": 43487
  },
  {
    "hostname": "web.localhost",
    "port": 4554,
    "pid": 43487
  }
]

The Portless log matched:

HTTPS/2 proxy listening on port 1355

The important implementation detail in Portless 0.15.1 is that 1355 is the built-in fallback proxy port. The installed package contains:

var FALLBACK_PROXY_PORT = 1355;

The same package also reuses persisted proxy state during auto-start. That is a subtle footgun for this repo: scripts/portless-dev.ts passes PORTLESS_PORT=443, but Portless can still discover ~/.portless/proxy.port and start or reuse 1355.

The upstream README says explicit PORTLESS_* env vars should take priority. In this installed version, the auto-start path still reused the persisted fallback port. Treat this as library behavior to guard around, not as a storefront bug.

Live socket evidence

The useful listener check is:

ss -ltnp

In the WSL incident, the relevant rows were:

LISTEN 100.92.195.54:443
LISTEN [fd7a:115c:a1e0::7001:c338]:443
LISTEN *:1355 users:(("node-MainThread",pid=32598,fd=22))

The network interface check showed those 443 listeners belonged to the WSL Tailscale interface:

7: tailscale0
    inet 100.92.195.54/32 scope global tailscale0
    inet6 fd7a:115c:a1e0::7001:c338/128 scope global

The WSL service status confirmed tailscaled was active and connected:

tailscaled.service - Tailscale node agent
Active: active (running)
Status: "Connected; ...; 100.92.195.54 fd7a:115c:a1e0::7001:c338"
listening on [fd7a:115c:a1e0::7001:c338]:443
listening on 100.92.195.54:443

The matching Serve status was:

https://desktop-qprihvm.tail0bb438.ts.net (tailnet only)
|-- / proxy http://127.0.0.1:4112

{
  "version": "0.0.1"
}

That URL has no explicit port, so it is the default HTTPS listener on 443 for the WSL Tailscale node. The upstream target, 127.0.0.1:4112, is not the conflict with Portless. It is only the local service that Tailscale Serve proxies to after Tailscale has already accepted traffic on tailnet port 443.

After tailscale serve reset, the Serve status became:

No serve config
{
  "version": "0.0.1"
}

At that point the 443 listeners were gone, but Portless still had an old fallback process:

LISTEN *:1355 users:(("node-MainThread",pid=32598,fd=22))

The matching process was:

/home/iiicoast/.local/share/nvm/v26.4.0/bin/node \
  /home/iiicoast/projects/kof-repo/node_modules/portless/dist/cli.js \
  proxy start --foreground --port 1355 --https --skip-trust

Stopping that Portless proxy removed the stale ~/.portless/proxy.port file:

node node_modules/portless/dist/cli.js proxy stop

This explains why turning off Windows-host Tailscale was not enough. WSL had its own tailscaled service, its own tailscale0 interface, and its own tailnet 443 listeners.

Why Tailscale can block a local URL

web.localhost resolves to loopback in browsers, so it is tempting to assume Tailscale should not matter. The conflict is lower-level than DNS.

Portless 0.15.1 creates its proxy server and calls:

server.listen(proxyPort)

That binds the proxy to the wildcard address for the port. For HTTPS clean URLs, that means Portless wants *:443, not just 127.0.0.1:443.

If another process already owns 100.92.195.54:443 or [fd7a:...]:443, a later attempt to bind *:443 can fail because the wildcard listener overlaps every local address on that port. Portless then uses its unprivileged fallback port, 1355, and includes that port in generated URLs.

The macOS case was the same class of failure. There, the listener was the Tailscale macOS system extension. In WSL, the listener was WSL's own tailscaled service.

Why tailscale down may not be enough

tailscale down changes Tailscale's network state, but it is not the same thing as clearing a persisted Serve configuration. The installed WSL CLI lists these controls:

tailscale serve status
tailscale serve reset
tailscale serve clear <service>
tailscale serve get-config
tailscale serve set-config

Background Serve configuration can resume after reboot or tailscale down / tailscale up. If Serve was configured in the background with the default HTTPS port, it can come back and re-own 443.

Use tailscale serve reset when the goal is "stop serving on 443" rather than "disconnect this machine from the tailnet." That is narrower than turning off Tailscale entirely.

Node version side note

Portless requires Node.js 24+. This repo has .nvmrc set to:

v26.4.0

During the incident, the running bun dev process was using:

/home/iiicoast/.local/share/nvm/v26.4.0/bin/node

One diagnostic shell saw:

node --version -> v23.7.0

That mismatch was not the :1355 root cause, because the actual bun dev run made it past the Node guard and launched Portless through Node 26.4.0. It is still worth checking during setup:

which node
node --version

If the version is below 24, run:

nvm use

or use the local Node version manager that owns .nvmrc on that machine.

Remediation path

Use this sequence when bun dev prints https://web.localhost:1355.

The node node_modules/portless/dist/cli.js ... commands below assume node --version is 24 or newer. Run nvm use first if the shell is still on an older Node.

1. Confirm the proxy state

cat ~/.portless/proxy.port
cat ~/.portless/proxy.tls
node node_modules/portless/dist/cli.js list
node node_modules/portless/dist/cli.js doctor

If proxy.port is 1355, Portless has persisted the fallback. doctor is still useful, but run it from a real interactive shell with Node 24+ so the result is not polluted by a stale PATH.

2. Confirm who owns 443

Linux or WSL:

ss -ltnp | rg ':443|:1355|tailscale'
ip addr show tailscale0
systemctl status tailscaled --no-pager

macOS:

sudo lsof -nP -iTCP:443 -sTCP:LISTEN

Expected failure signatures:

  • tailscaled or a Tailscale address owns 443.
  • macOS shows an io.tailscale listener.
  • Portless is listening on *:1355.

3. Remove only the conflicting Tailscale Serve listener

First inspect Serve state:

tailscale serve status
tailscale serve get-config --all

If this WSL instance does not need to serve anything over the tailnet on 443, reset Serve:

tailscale serve reset

For the observed status, this removes the root proxy:

https://desktop-qprihvm.tail0bb438.ts.net
|-- / proxy http://127.0.0.1:4112

If tailnet sharing of that same local service is still needed, move it off 443 and keep it in the background:

tailscale serve --https=8443 --bg 127.0.0.1:4112

That keeps Tailscale running while reserving local 443 for Portless. The tailnet URL would then include the explicit alternate port.

4. Force Portless back to 443

Stop the fallback proxy:

node node_modules/portless/dist/cli.js proxy stop

After the stop, verify that the stale listener and persisted fallback state are gone:

ss -ltnp | rg ':443|:1355|tailscale'
test ! -e ~/.portless/proxy.port && echo "no persisted Portless proxy port"

If the listener check prints nothing for 443 or 1355, Portless has a clean chance to claim 443 on the next start.

Then start Portless explicitly on 443 from an interactive terminal:

node node_modules/portless/dist/cli.js proxy start --port 443 --https

Portless may ask for sudo. Accepting that prompt is expected because 443 is a privileged port on Linux and macOS.

Check the state:

cat ~/.portless/proxy.port

Expected:

443

Then run:

bun dev

Expected:

web   https://web.localhost
api   https://api.localhost
docs  https://docs.localhost

5. Clean only if state is stale

Use portless clean as a repair tool when state is inconsistent or stale, not as the first move:

node node_modules/portless/dist/cli.js clean

It can remove the local CA trust entry and state files, so it may require another trust prompt later.

Browser certificate trust failure

Clean Portless routing can be healthy while the browser still rejects one of the local hostnames. The symptom looks like this in Firefox-family browsers, including Zen:

GET https://api.localhost/.../snapshot
NS_ERROR_GENERATE_FAILURE(NS_ERROR_MODULE_SECURITY, SEC_ERROR_UNKNOWN_ISSUER)

If https://web.localhost loads but the storefront shows "Inventory API unavailable", check the API directly:

curl -k https://api.localhost/health

Expected:

{"ok":true,"service":"smokeshop-api","paymentProvider":"pay-at-pickup"}

If curl -k succeeds but the browser fails with SEC_ERROR_UNKNOWN_ISSUER, this is certificate trust, not Portless routing, CORS, Hono, or Vite. Firefox certificate exceptions are hostname-specific, so accepting a warning for web.localhost does not also trust api.localhost or docs.localhost.

Preferred fix:

node node_modules/portless/dist/cli.js trust

Then restart the browser. If the browser still does not trust the certificate, use one of these browser-side fixes:

  • Enable security.enterprise_roots.enabled in about:config if the browser should trust the operating system certificate store.
  • Import ~/.portless/ca.pem into the browser certificate authorities and trust it for website identification.
  • On a Windows browser pointed at WSL, import the same file through the WSL share path, for example \\wsl.localhost\Ubuntu\home\iiicoast\.portless\ca.pem.

Quick local workaround:

Open https://api.localhost/health in the same browser and accept the certificate exception for that host.

That gets the API fetches moving, but importing the CA is cleaner because it covers web.localhost, api.localhost, and docs.localhost together.

Accepting the fallback

The fallback URL is not inherently broken:

https://web.localhost:1355

The app intentionally preserves the active Portless proxy port when deriving API and checkout origins. That means the fallback is a usable development mode.

The tradeoffs are:

  • browser URLs are noisier
  • OAuth, auth callback, and payment return URLs may need explicit :1355 entries when testing
  • screenshots and client review links no longer match the clean docs examples
  • a future run may keep using 1355 until Portless state is corrected

Use the fallback when getting a screen reviewed matters more than clean URLs. Use the remediation path when testing auth, checkout redirects, or handoff instructions.

Longer-term options

The best upstream fix would be a Portless option to bind the local .localhost proxy to loopback only:

127.0.0.1:443
::1:443

That would let Tailscale own 100.x.y.z:443 for tailnet traffic while Portless owns loopback 443 for local browser traffic. Portless 0.15.1 does not document such an option. Its --ip option is for LAN mode, not local proxy bind address selection.

Repo-side options:

  • Keep bun dev as the clean-url path and document the Tailscale Serve reset workflow.
  • Keep bun run dev:plain as the guaranteed fallback for machines where another service must own 443.
  • Keep the scripts/portless-dev.ts preflight that pins PORTLESS_STATE_DIR to ~/.portless, reads ~/.portless/proxy.port, prompts before clearing a non-443 Portless proxy, and explicitly starts portless proxy start --port 443 --https before launching apps.
  • Keep terminal cleanup in both dev wrappers so Ctrl-C restores cursor visibility, disables bracketed-paste/mouse tracking leftovers, and runs stty sane on Unix-like systems.
  • Consider a repo-local Portless state directory, such as PORTLESS_STATE_DIR=.portless-state, only if cross-project state becomes a repeated problem. That would isolate persisted fallback state, but it would not solve a real 443 listener conflict.

Final diagnosis

The immediate WSL incident was not caused by Vite, Hono, React Router, CORS, or the storefront route model. It was a local proxy ownership issue:

  1. Clean Portless URLs require Portless to bind 443.
  2. WSL tailscaled owned 443 on the tailscale0 addresses.
  3. Portless fell back to 1355.
  4. Portless persisted that fallback in ~/.portless/proxy.port.
  5. Later bun dev runs kept showing :1355.

The least disruptive fix is to reset or move Tailscale Serve on this WSL instance, then explicitly restart Portless on 443. That preserves Tailscale networking while freeing the one local port Portless needs for clean .localhost URLs.

On this page