---
title: "The IPv6 fix bit back: how localhost resolving to ::1 quietly broke a three-hop proxy chain"
canonical: https://dxdev.com/blog/localhost-resolves-to-ipv6-broke-three-hop-proxy/
datePublished: 2026-05-07
---
In the morning I disabled IPv6 on my Windows box and a class of page-load failures went away. That evening the entire site started hanging for ten seconds before it would load, and the cause was the exact change I had made twelve hours earlier. `localhost` resolving to `::1` first had quietly broken a three-hop reverse-proxy chain, and `curl` with timing flags walked the chain hop by hop until the dead link fell out.

## The morning fix

Earlier that day I had been chasing a different ghost: Cloudflare-fronted sites like Mixcloud would stall in both browsers while the network looked perfectly healthy on paper. Pings were clean, DNS resolved fast, TCP handshakes completed. The real cause was a half-disabled IPv6 stack. The per-adapter `ms_tcpip6` binding was unchecked on Wi-Fi and Ethernet, but `HKLM\...\Tcpip6\Parameters\DisabledComponents` was never set, so Windows still asked DNS for AAAA records, still preferred IPv6 in Happy Eyeballs, and still opened v6 sockets that black-holed before falling back to v4.

The fix was to set `DisabledComponents = 0x20`, which tells Windows to prefer IPv4. I chose `0x20` over `0xFF` deliberately so Tailscale's IPv6 kept working. The morning fix is its own post. What matters here is that after it landed, anything on this box still reaching for IPv6 was reaching into a deprioritized stack that could black-hole instead of answering. Hold that thought.

## The evening symptom

Late that evening I was debugging a "session not found" error on one of my apps and noticed the whole site had gone slow. Loading `https://myapp.example.com/` returned `code=000` after a full ten-second timeout. The TLS handshake never completed. Meanwhile hitting the box on `localhost` was instant. So the app was alive; something between the edge and the app was eating ten seconds and then giving up.

The serving stack for that site is three hops deep:

```
browser  ->  IIS (443)  ->  Caddy (8443)  ->  node app (3001)
```

Three places a request can die. The temptation is to guess which one, but walking each hop with a timed probe is faster than any guess.

## Walking the chain with curl

The single tool that carried this entire debug was `curl` with two flags that strip everything except what matters:

```
curl.exe -s -m 10 -o NUL -w "code=%{http_code} time=%{time_total}s"
```

`-o NUL` throws away the body. `-w` prints only the HTTP status code and the total time. `-m 10` caps each probe at ten seconds so a black hole can't hang you forever. With that template you can hit each layer in isolation, innermost first, and read two numbers off each one.

The time tells you more than the code:

- `code=200` and fast: that hop is healthy, move outward.
- `code=000 time=0.03s`: connection refused. Nothing is listening on that address and port. The failure is instant because the OS rejects it immediately.
- `code=000 time=10s`: never completed. The connection black-holed and ate the full timeout. That is the signature of a connect attempt going to an address that silently drops packets, which is exactly what a disabled IPv6 listener looks like to an IPv4 client and vice versa.

Innermost first. `curl http://localhost:3001/` returned an instant 200. The node app was healthy. So the rot was upstream of the app, in the proxy layers.

Then Caddy. This is where the IPv6 change announced itself. Probing Caddy on 8443 refused fast: `code=000` in about 37 milliseconds. Caddy was listening only on the IPv6 wildcard `::`, so the v4 side of the port had nothing bound to it. Instant refusal, classic `code=000 time=0.03s` shape.

One more tool earned its place here:

```
Get-NetTCPConnection -LocalPort 8443
```

No `-State` filter on purpose. Leaving the filter off shows every listener on that port, so you can see at a glance which stack is actually bound, v4 or v6. Caddy was on v6 only. That confirms what the curl refusal implied instead of leaving it as a hunch.

There is also an SNI trap waiting at this layer. If you point `curl` at `https://127.0.0.1:8443` against a name-vhosted server, the handshake can drop because the SNI does not match a site block, and you will misread that as a connection failure. The way out is to force the SNI while still routing to a literal IP:

```
curl --resolve myapp.example.com:8443:127.0.0.1 https://myapp.example.com:8443/
```

That sends the real hostname in SNI but dials the IP you told it to. It separates "is the name routing wrong" from "is the listener on the wrong stack."

## The obvious culprit was a red herring

At this point Caddy looked guilty, so I pinned it: `default_bind 127.0.0.1` and changed `reverse_proxy localhost:3001` to `reverse_proxy 127.0.0.1:3001`. Both good hygiene. But the site was still hanging.

For `myapp.example.com`, Caddy was not in the request path at all. IIS had a rewrite rule that proxied straight to the node app, and Caddy only sat in the certificate-renewal path. So the Caddy edit was real cleanup, but it was not the fix. I had pinned the wrong hop.

The actual offender was the IIS rewrite rule in the site's `web.config`, which forwarded to:

```
http://localhost:3001/{R:1}
```

`localhost`. On this machine `localhost` resolves to `::1` before `127.0.0.1`, so IIS tried the IPv6 loopback first. The node app was listening on the IPv4 loopback. Before the morning change that v6 attempt got refused instantly and fell through to v4, which is why nobody had ever noticed. After it, the v6 connect black-holed, ate the connect timeout, and only then fell back. Ten seconds of dead air on every single request, on a path I had not even suspected because I was staring at Caddy.

The fix was a few characters. Rewrite the upstream to the literal IPv4 loopback:

```
http://127.0.0.1:3001/{R:1}
```

Then `iisreset`. The same public probe that had hung for the full ten seconds now came back in 18 milliseconds. That number is the proof: the IPv6 black hole was gone, the request reached IIS and got an answer fast. The answer happened to be a 503, because the `iisreset` stop had hung the app pool, but that is a different failure entirely and it failed in milliseconds, not seconds. After a separate app-pool cleanup the page served clean, around 50 milliseconds end to end.

So restarting IIS to apply a `web.config` change is not always clean. Read the timing anyway: a 10s hang becoming an 18ms 503 is the IPv6 fix landing, even though the page is not done yet.

## Why this hides so well

The reason this bug is nasty is that nothing in it is broken in the way you expect "broken" to look. The app is up. The config is syntactically fine. `localhost` is not a typo. DNS is not misconfigured. Every layer, examined on its own with the wrong tool, looks healthy.

What changed is invisible: the resolution order of a name. `localhost` is not an address, it is a name that resolves to a list, and on Windows that list puts `::1` ahead of `127.0.0.1`. Multiply that across three services that each independently proxy to `localhost` and you get a chain where any hop can be the ten seconds, and you cannot tell which one without measuring.

A refused connection fails in tens of milliseconds. A black-holed connection fails in whole seconds. `code=000` with a 10-second time is a different animal from `code=000` at 30 milliseconds. The time is the diagnosis. Refusal is milliseconds, a black hole is seconds, and the gap between them is the hop that is lying to you. That is the whole reason `-o NUL -w "code=%{http_code} time=%{time_total}s"` is worth memorizing.

## Related

- ["My internet feels unstable" was a half-disabled IPv6 stack, not the ISP](/blog/ipv6-half-disabled-stack-not-the-isp/): the morning fix that caused this evening's problem
- [My whole DNS provider split died instantly: keep a second DoH provider on hand](/blog/keep-a-second-doh-provider-dns-google-fallback/): another case where a name resolves unexpectedly after a network change
- ["Bamboo is broken" was wrong: a deploy that races the filesystem under CPU pressure](/blog/bamboo-not-broken-deploy-races-filesystem-under-cpu-pressure/): another multi-hop failure that looks healthy at each step individually
- [UTC logs, a local clock, and the canary request: timezone discipline in an incident](/blog/canary-request-utc-logs-incident-timing/): using timing data to isolate which hop in a chain is broken
- [An Off-Origin Caddy Relay for Bare-Domain SSL (Because Cloudflare Pro Won't Proxy Your Apex)](/blog/off-origin-caddy-relay-bare-domain-ssl-cloudflare-pro/): Caddy proxy binding decisions that matter on the same stack
