ShipLocal build series · Part 27
IPv6, localhost, and Tunnel Connection Refused
Your browser works on localhost but the CLI cannot reach your dev server.
One of the most confusing tunnel bugs:
Chrome opens
http://localhost:3000fine.
ShipLocal returns 502 / connection refused.
From the user’s perspective, “localhost works” and “the tunnel is broken” are the same sentence.
From ours, the CLI and the browser may not be talking to the same listener.
What the CLI actually dials
ShipLocal’s local forwarder tries loopback hosts in order:
const LOOPBACK_HOSTS = ['127.0.0.1', '::1'] as const;
If both refuse the connection, you get the classic error path:
Nothing is listening on port 3000 (tried 127.0.0.1 and ::1).
Your browser’s localhost resolution might prefer IPv6 (::1) or hit a different binding than the CLI’s first attempt — or your server may only be listening on an unexpected interface.
Common binding mismatches
Dev server on IPv6-only / odd defaults
Some setups listen in a way that “localhost in Chrome” works while 127.0.0.1 from Node does not (or the reverse).
Guidance we surface in errors:
next dev -H 0.0.0.0
(or bind explicitly to 127.0.0.1)
Docker published ports
“Works in browser on localhost:3000” can mean Docker published a host port, while the process inside the container is bound differently. The CLI on the host must reach the host-published port.
Wrong port
Dashboard in this monorepo runs on 3001. Guessing 3000 produces the same ECONNREFUSED story.
shiplocal doctor --port <n> checks whether anything is listening before you blame the tunnel protocol.
What users see through the preview URL
When the CLI can’t reach the local app, the preview often becomes a 502-style failure with a message about nothing listening on that port.
That is correct tunnel behavior: the control plane is up; the local origin is not reachable from the CLI process.
Triage checklist
- Confirm the app URL in the browser (exact port)
- Run
shiplocal doctor --port <that-port> - If doctor says port closed, fix the app bind/start first
- Prefer binding to
0.0.0.0or127.0.0.1explicitly in stubborn environments - Only then dig into proxy/HMR/protocol issues
Don’t start with streaming architecture when the TCP connect never succeeds.
What we learned
localhost is not one address.
It’s a family of names and stacks that disagree just often enough to waste an afternoon.
Tunnel CLIs should try both IPv4 and IPv6 loopbacks — and explain the failure in human language when both refuse.