>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

ECONNREFUSED from another host: the 127.0.0.1 bind trap

Noah Lindqvist covers hardware, processes and Unix internals at C2CZ. Author page · All authors.

Error: connect ECONNREFUSED 172.18.0.6:18080 from another host, while curl http://127.0.0.1:18080/ answers 200 on the same machine — that pair of facts is a complete diagnosis, and it is almost never the firewall. A process bound to 127.0.0.1 owns exactly one address; every packet addressed to a different address of that host reaches the kernel with no listener, and the kernel answers the connection attempt itself with a TCP RST. Nothing is logged on the server side, no service ever sees the request, and there is no rule to fix in iptables.

This is the failure behind most “my container can’t reach my service on the host”, “my reverse proxy gets a 502 but the app works locally” and “WireGuard peer gets connection refused to a service I can curl on the box” reports. I spent a morning chasing it through a firewall rule that was never the problem, so this post is the measurement I wish I had: one Python origin, three bind strings, four clients, and the elapsed-time signature that separates “refused” from “dropped” in a single line of output.

Everything below was executed on this host before publishing (Python 3.13.5, Node v26.5.1, curl 8.14.1, Linux 6.17 aarch64, bindv6only=0), and the commands are self-contained and self-terminating, so you can paste them straight into a scratch shell.

TL;DR

  • A refusal is instant, a firewall drop is not. Measured: refused connection returns in 0.1 ms with errno 111; a blackholed address returns after the full timeout (errno 11 EAGAIN in 3003.4 ms). If the client “hangs for 30 seconds then times out”, the listener is fine and something on the path is dropping packets. If it fails in under a millisecond, no process holds that address.
  • Bind address is per-socket, not per-port. The same port number can listen on loopback only, on one interface only, or on all of them — and clients are routed by destination address, not by port. 127.0.0.1:8080 and 172.18.0.6:8080 are different sockets even though both are “port 8080 on this machine”.
  • Measured, address by address: bound to 127.0.0.1 → loopback http_code=200, container address curl: (7) Failed to connect ... after 0 ms, Python errno 111 ECONNREFUSED in 0.1 ms, Node Error: connect ECONNREFUSED ... [code=ECONNREFUSED]. Bound to 0.0.0.0 → both addresses answer 200 and both clients connect in under 3 ms.
  • You do not need ss or netstat. /proc/net/tcp and /proc/net/tcp6 list every listener with its local address; the snippet below decodes the little-endian hex into 127.0.0.1:18080 [SINGLE-INTERFACE] versus 0.0.0.0:18080 [ALL-INTERFACES]. That one word is the whole diagnosis.
  • Node’s default wildcard is IPv6, not IPv4. server.listen(18082) prints listening on :::18082 family=IPv6 — with bindv6only=0 that also covers IPv4, which is why a second listener dies with Error: listen EADDRINUSE: address already in use :::18082. The ::: in that message is not a typo and not a duplicate of 0.0.0.0.
  • The fix is one config value in the service, not a port change: uvicorn --host 0.0.0.0, gunicorn -b 0.0.0.0:8000, Node listen(port, "0.0.0.0"), PHP-FPM listen = 0.0.0.0:9000, Redis bind 0.0.0.0 ::, Postgres listen_addresses. Then re-run the listener check and confirm [ALL-INTERFACES].
  • Widening the bind is a security decision. 0.0.0.0 means every interface, including the public one. Do it deliberately: bind wide, firewall narrow, and keep the audit command in your runbook (see the checklist at the end).

Refused in 0.1 ms, or no answer at all

Before you touch a config file, measure how long the failure takes. The kernel distinguishes the two cases for you, and the distinction is decisive: ECONNREFUSED is produced by the target host’s own TCP stack when no socket is bound to that address and port, while an unanswered handshake is produced by a packet filter, a security group, or a route that goes nowhere. The first is a binding problem you can fix locally; the second is a network policy problem.

The probe below reports both the errno and the wall-clock cost, which is why it is more useful than a bare curl. Save it as probe.py and run it against a live service and against an address that is filtered.

#!/usr/bin/env python3
"""Connect probe that prints the errno AND the elapsed time (refused vs dropped)."""
import errno
import socket
import sys
import time

host = sys.argv[1]
port = int(sys.argv[2])
timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 3.0

fam = socket.AF_INET6 if ":" in host else socket.AF_INET
s = socket.socket(fam, socket.SOCK_STREAM)
s.settimeout(timeout)
t0 = time.perf_counter()
rc = s.connect_ex((host, port))
ms = (time.perf_counter() - t0) * 1000.0
s.close()
if rc == 0:
    print(f"{host}:{port} -> connected in {ms:.1f} ms")
elif rc == errno.ECONNREFUSED:
    print(f"{host}:{port} -> errno {rc} ECONNREFUSED in {ms:.1f} ms")
else:
    print(f"{host}:{port} -> errno {rc} {errno.errorcode.get(rc)} in {ms:.1f} ms")

Run against a port nothing listens on, and against 10.255.255.1, an address that accepts nothing and answers nothing. The measured output is the discriminator I use before every other step:

$ python3 probe.py 172.18.0.6 18080 3
172.18.0.6:18080 -> errno 111 ECONNREFUSED in 0.1 ms
$ python3 probe.py 10.255.255.1 18081 3
10.255.255.1:18081 -> errno 11 EAGAIN in 3003.4 ms

Two orders of magnitude, two different investigations. The refused case completed in a tenth of a millisecond because the RST came back from the target host’s own stack; the second consumed the entire 3-second budget because SYN packets were dropped in silence. A Node client renders the same two cases as Error: connect ECONNREFUSED and as no event at all until your own timeout fires.

connect_ex() returns the errno instead of raising, which makes it safe inside a loop of targets. And EAGAIN is not a network error code in the usual sense: with a non-blocking connect plus a timeout, it is what the socket layer reports when the handshake never completed — exactly what a firewall DROP looks like from userland.

The reproducer: one origin, two bind strings, three clients

Now the part that surprises people. Start an HTTP origin bound to 127.0.0.1, then start the identical origin bound to 0.0.0.0, and probe both from the loopback address and from the host’s own non-loopback address. The client does not change, the port does not change, the firewall does not change: only the bind() argument changes, and the reachability of the service changes completely.

The script derives the non-loopback address by opening a UDP socket to a public address and reading the local end back — no packets are sent, and it works in containers with no default route surprises. It is self-terminating, so it will not leave an orphan listener behind.

#!/bin/bash
# One origin, two bind strings, three clients. Self-terminating; safe to paste.
set -u
PORT=18080
HOSTIP=$(python3 -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('1.1.1.1',53));print(s.getsockname()[0])")
cat > /tmp/c2cz-origin.py <<'PY'
import socket, sys
host, port = sys.argv[1], int(sys.argv[2])
srv = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(16)
srv.settimeout(4.0)
while True:
    try:
        conn, _ = srv.accept()
    except (socket.timeout, TimeoutError):
        break
    conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nk=ok\n")
    conn.close()
PY
echo "### HOSTIP=$HOSTIP PORT=$PORT"
for BIND in 127.0.0.1 0.0.0.0; do
  echo "=== origin bound to $BIND:$PORT ==="
  python3 /tmp/c2cz-origin.py "$BIND" "$PORT" &
  SRV=$!
  sleep 1
  curl -s -o /dev/null -w "  curl 127.0.0.1  http_code=%{http_code} time_connect=%{time_connect}s\n" "http://127.0.0.1:$PORT/"
  curl -sS -o /dev/null -w "  curl HOSTIP     http_code=%{http_code}\n" "http://$HOSTIP:$PORT/" 2>&1 || echo "  curl exit=$?"
  python3 -c "import socket,time;t=time.perf_counter();s=socket.socket();s.settimeout(3);rc=s.connect_ex(('$HOSTIP',$PORT));print(f'  python errno={rc} in {(time.perf_counter()-t)*1000:.1f} ms')"
  kill "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null
done

The measured run on this host, trimmed to the interesting lines:

### HOSTIP=172.18.0.6 PORT=18080
=== origin bound to 127.0.0.1:18080 ===
  curl 127.0.0.1  http_code=200 time_connect=0.000187s
curl: (7) Failed to connect to 172.18.0.6 port 18080 after 0 ms: Could not connect to server
  curl HOSTIP     http_code=000
  curl exit=7
  python errno=111 in 0.1 ms
=== origin bound to 0.0.0.0:18080 ===
  curl 127.0.0.1  http_code=200 time_connect=0.000151s
  curl HOSTIP     http_code=200
  python errno=0 in 0.2 ms

The only difference between the passing and the failing run is the argument to bind(). Note the curl wording as well: this build prints Could not connect to server where older builds and most blog posts show Connection refused — same exit 7, same http_code=000, so do not let the phrasing send you looking for the wrong thing.

The same failure in the client that reports it most loudly

Node surfaces the errno in the message itself, which is why the string travels so well in issue trackers. Four lines of node:net reproduce it:

// node-probe.mjs — prints the code and the elapsed time, then exits 0.
import net from "node:net";

const host = process.argv[2];
const port = Number(process.argv[3]);
const t0 = performance.now();
const sock = net.connect({ host, port });

sock.on("connect", () => {
  console.log(`connected to ${host}:${port} in ${(performance.now() - t0).toFixed(1)} ms`);
  sock.end();
  process.exit(0);
});
sock.on("error", (err) => {
  console.log(`${err.name}: ${err.message} [code=${err.code}] after ${(performance.now() - t0).toFixed(1)} ms`);
  process.exit(0);
});
setTimeout(() => {
  console.log(`no answer from ${host}:${port} in 3000 ms (packets dropped, not refused)`);
  process.exit(0);
}, 3000);

Against the loopback-bound origin it prints Error: connect ECONNREFUSED 172.18.0.6:18080 [code=ECONNREFUSED] after 3.7 ms; against the same origin bound to 0.0.0.0 it prints connected to 172.18.0.6:18080 in 2.5 ms. Against the blackholed address it prints no answer from 10.255.255.1:18081 in 3000 ms (packets dropped, not refused) — three strings, three root causes, one script.

What /proc/net/tcp tells you when ss and netstat are missing

Minimal container images increasingly ship without iproute2 (ss) and with no net-tools (netstat), and installing them in a production image to answer a one-off question is the wrong trade. The kernel exposes the same table at /proc/net/tcp and /proc/net/tcp6. The local address is the first field: a hex IPv4 address in little-endian order, then the port. 0100007F is 127.0.0.1; 00000000 is 0.0.0.0; st 0A means LISTEN.

This decoder prints one line per listening socket and labels it, which is the entire point: it answers “did my service actually widen its bind?” without a single extra package.

#!/usr/bin/env python3
"""Decode listeners from /proc/net/tcp{,6}; optional port filter as argv[1]."""
import socket
import sys

TARGET_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else None


def ipv4(hexip):
    return socket.inet_ntoa(bytes.fromhex(hexip)[::-1])


def ipv6(hexip):
    # /proc/net/tcp6 stores the address as four little-endian 32-bit words.
    raw = bytes.fromhex(hexip)
    packed = b"".join(raw[i : i + 4][::-1] for i in range(0, 16, 4))
    return socket.inet_ntop(socket.AF_INET6, packed)


def rows(path, v6):
    try:
        lines = open(path).read().splitlines()[1:]
    except FileNotFoundError:
        return
    for line in lines:
        f = line.split()
        if f[3] != "0A":  # 0A = LISTEN
            continue
        hexip, hexport = f[1].split(":")
        port = int(hexport, 16)
        if TARGET_PORT is not None and port != TARGET_PORT:
            continue
        addr = ipv6(hexip) if v6 else ipv4(hexip)
        scope = "ALL-INTERFACES" if addr in ("0.0.0.0", "::") else "SINGLE-INTERFACE"
        print(f"LISTEN {addr}:{port}  [{scope}]")


for path, v6 in (("/proc/net/tcp", False), ("/proc/net/tcp6", True)):
    rows(path, v6)

Run it while the origin is bound to loopback, then bound wide, then bound to the IPv6 wildcard. Three runs, three distinct verdicts:

$ python3 listeners.py 18080          # origin bound to 127.0.0.1
LISTEN 127.0.0.1:18080  [SINGLE-INTERFACE]
$ python3 listeners.py 18080          # origin bound to 0.0.0.0
LISTEN 0.0.0.0:18080  [ALL-INTERFACES]
$ python3 listeners.py 18080          # origin bound to ::
LISTEN :::18080  [ALL-INTERFACES]

That is the check to run in production too, since it needs no arguments and no packages. If your service answers on localhost and the label says [SINGLE-INTERFACE], you are done diagnosing — the rest of this article is about the fix and about why the loopback-only bind survives so long in development.

The IPv6 wildcard is the other half of the trap

On Linux the IPv6 wildcard :: is usually dual-stack: with bindv6only=0 (the default on this host, and on every mainstream distribution and container runtime) a socket bound to :: accepts IPv4 connections as well, delivered as v4-mapped addresses such as ::ffff:172.18.0.6. That is why so many Node and Go services “work” without anyone writing 0.0.0.0 anywhere: their default wildcard is ::.

The measured server log shows the mapping directly. One origin bound to ::, three clients, three peer addresses:

[srv] bound AF_INET6 :::18080
[srv] served ::ffff:127.0.0.1
[srv] served ::1
[srv] served ::ffff:172.18.0.6

The consequence is a collision that looks unrelated to the error message. Because :: covers the v4 wildcard when bindv6only=0, 0.0.0.0 and :: contend for the same port. This five-line check asks the kernel who is allowed to take the port, one wildcard at a time, and reports the errno for each attempt:

#!/usr/bin/env python3
"""Who owns the port? Try the three usual wildcards against a live holder."""
import socket
import sys

port = int(sys.argv[1]) if len(sys.argv) > 1 else 18080

for host in ("0.0.0.0", "127.0.0.1", "::"):
    fam = socket.AF_INET6 if ":" in host else socket.AF_INET
    s = socket.socket(fam, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        s.bind((host, port))
        print(f"   bind {host}:{port} -> OK")
    except OSError as exc:
        print(f"   bind {host}:{port} -> OSError {exc.errno}: {exc.strerror}")
    finally:
        s.close()

I had Node hold the port with its default listen() and then ran that check; all three wildcards failed, and Node’s own second attempt produced the string that ends up pasted into issue trackers everywhere:

=== node server.listen(PORT), default host ===
listening on :::18082 family=IPv6
=== while that socket holds the port ===
   bind 0.0.0.0:18082 -> OSError 98: Address already in use
   bind 127.0.0.1:18082 -> OSError 98: Address already in use
   bind :::18082 -> OSError 98: Address already in use
=== node server.listen on the same port, second process ===
Error: listen EADDRINUSE: address already in use :::18082 [code=EADDRINUSE]

Note what the message actually claims: the IPv6 wildcard is in use, even though the process that grabbed the port was also a Node default bind. If you read :::3000 as “a duplicate of 0.0.0.0:3000, so something must be broken”, you will chase a phantom. The correct reading is “a dual-stack listener already owns this port”, and the fix is to find and stop it, not to change the address family of your own service.

One asymmetry explains the “it works for the container but not from the LAN” cases a wildcard checklist misses: 0.0.0.0 accepts no IPv6 clients, and a socket bound to ::1 accepts nothing over IPv4. Where clients arrive over an IPv6 transport by default, a service bound to 0.0.0.0 is unreachable while the checklist still says “all interfaces”. Bind :: on dual-stack platforms, or bind both explicitly.

Why containers, reverse proxies and VPN peers keep hitting this

The loopback bind is the natural result of local development: you start the app, you open http://localhost:3000, it works, and you never look at the bind argument again. Then production changes who dials the socket, and the address the client uses stops being 127.0.0.1.

ClientAddress it dialsResult against a loopback-only listener
Another process on the same host127.0.0.1:3000Works — this is the case that hid the bug
Docker published port (-p 8080:8080)the container’s bridge address, e.g. 172.18.0.6ECONNREFUSED; the DNAT rewrites the destination to the container address, which the loopback bind does not own
Reverse proxy in a different containerthe upstream container name or its bridge IP502 from the proxy, ECONNREFUSED in its error log
Reverse proxy on the same host, upstream 127.0.0.1loopbackWorks — the reason a proxy can mask the bug until the app is re-hosted
WireGuard or Tailscale peerthe tunnel address, e.g. 10.7.0.1 or 100.x.y.zECONNREFUSED; the tunnel interface is a normal interface, not loopback
Browser on the LANthe host’s LAN addressECONNREFUSED (filtered, it would be a timeout instead)

The container row is worth spelling out because the mental model matters more than the command. Publishing a port does not “expose the machine’s port 8080”; it installs a DNAT rule that rewrites the destination to the container’s address and forwards it there. Inside the container namespace the packet is addressed to 172.18.0.6:8080. A process bound to 127.0.0.1:8080 owns only the loopback address of that namespace, so the kernel refuses it exactly as measured above. This host runs no Docker daemon — docker ps here answers Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — so treat the packet-rewriting description as documented behaviour of the container runtime rather than something reproduced in this article; the socket-level measurements above are independent of it and are what you will see on your own box.

The VPN row catches experienced operators because the tunnel address feels local. It is not: a WireGuard interface is an ordinary network interface with an ordinary address, and a listener bound to 127.0.0.1 is unreachable through it. If the same service is behind a reverse proxy that talks to it over loopback, everything looks healthy from the outside while the direct peer-to-peer path stays refused, which is a useful sanity check: test both paths whenever you change a bind address.

For the network-policy half of the picture — a client that cannot even reach the host rather than one that reaches it and is refused — the error strings and the investigation order are different: Docker’s “request canceled while waiting for connection” is the canonical example of a client-side timeout that has nothing to do with the port you suspect, and the four branches behind “Cannot connect to the Docker daemon” shows how one socket-path message splits into four unrelated root causes. Start with those two before you go looking for a filter that is not there.

The fix: bind to the address the client actually dials

The repair is one value per service, and the correct target depends on where the clients come from. Bind to the container’s own interface address when only the reverse proxy needs to reach it; bind to 0.0.0.0 (or ::) only when the interface set is genuinely open-ended. Then confirm with listeners.py rather than assuming.

ServiceLoopback-only defaultBind wider
Uvicorn--host 127.0.0.1--host 0.0.0.0 (or the container address)
Gunicorn-b 127.0.0.1:8000-b 0.0.0.0:8000
Node (http.Server)listen(port, "127.0.0.1")listen(port, "0.0.0.0"); omitted host means ::, i.e. dual-stack
Python http.server--bind 127.0.0.1--bind 0.0.0.0
PHP-FPMlisten = 127.0.0.1:9000listen = 0.0.0.0:9000 (or a unix socket, best when the web server is local)
Redisbind 127.0.0.1 -::1 in the shipped redis.conf, verified on the 7.2 branchbind 0.0.0.0 ::, plus protected-mode no only if you also have authentication and a firewall
PostgreSQL#listen_addresses = 'localhost' in the shipped postgresql.conf.sample, verified on the 17 branchlisten_addresses = '*', then a matching pg_hba.conf line

Two of those rows deserve a warning rather than a copy-paste. Redis with bind 0.0.0.0 and no authentication is one of the most reliably exploited misconfigurations on the public internet, so pair it with requirepass, a firewall rule, or (better) keep the bind loopback and let a proxy that lives on the same host reach it. Postgres with listen_addresses = '*' is only half a change: the accept loop widens and the authentication rules in pg_hba.conf decide what happens next, so change both in the same commit.

After the change, verify from the client side, not from the service side, and verify both addresses: the loopback call proves the process is alive, the non-loopback call proves it is reachable. When a container is involved, restart the container before testing — a bind argument baked into the image entrypoint is not affected by an environment change, and I have lost more time to that than to the bind address itself.

# Requires the two scripts above (save them as probe.py and listeners.py) plus a live service.
# From inside the container: what is really listening?
python3 listeners.py 8080
# From the host: does the published port answer, and how fast?
curl -s -o /dev/null -w "http_code=%{http_code} time_connect=%{time_connect}s\n" http://127.0.0.1:8080/
curl -s -o /dev/null -w "http_code=%{http_code}\n" http://172.18.0.6:8080/   # container address
# From a WireGuard/Tailscale peer: the tunnel path, which loopback never serves
python3 probe.py 100.101.102.103 8080 3

Widening a bind is a deliberate act

The reason to reach for the listener check before changing anything is that binding wider is not a neutral debugging step. 0.0.0.0 means every interface the host presents, including the public one the moment your firewall or security group allows it. I treat it as a two-step change: widen the bind, then decide the perimeter, and I audit the result with the same decoder.

  • Audit what is exposed: python3 listeners.py with no argument lists every listener on the host with its scope. Anything marked [ALL-INTERFACES] is a candidate for review; a database on [ALL-INTERFACES] is a finding, not a configuration choice.
  • Prefer the narrow bind when a proxy is local: a reverse proxy on the same host can dial 127.0.0.1:8080, so the app never needs the wide bind. Wide binds are for traffic that genuinely arrives on other addresses.
  • Keep the tunnel path narrow: a service that should only be reachable over WireGuard or Tailscale can bind the tunnel address specifically (10.7.0.1, 100.x.y.z) instead of every interface. That is a smaller surface than 0.0.0.0 plus a firewall rule, and it fails closed when the tunnel drops.
  • Do not confuse the listening scope with the client source: binding 0.0.0.0 does not admit every client by itself. Your kernel firewall still applies, so verify with a probe from a machine that should be refused, not with a probe from the host.

The two earlier posts linked above cover the other failure families on the same path: the client error that hides a completely different root cause, and the connection error that a daemon produces when it cannot reach what it needs at all. Between them and this one, you can classify almost every “connection failed” report into refused, dropped, or mis-addressed before you open a config file.

Troubleshooting table: which string means what

StringClientMeans
errno 111 ECONNREFUSED (0.1 ms)PythonNothing bound to that destination address and port; check the listener scope before the firewall
Error: connect ECONNREFUSED 172.18.0.6:18080 [code=ECONNREFUSED]NodeSame as above; the destination is printed, so compare it with the listener’s local address
curl: (7) Failed to connect ... after 0 ms: Could not connect to servercurlRefusal; older builds print Connection refused for the identical condition
errno 11 EAGAIN after the full timeoutPythonPackets dropped, not refused — firewall, security group, or a dead route
no answer from <host> in 3000 ms (packets dropped, not refused)Node (own timeout)Same as above; the client library never saw a reply
OSError 98: Address already in use / Error: listen EADDRINUSE: address already in use :::3000Python / NodeA dual-stack wildcard already owns the port; find and stop it, do not change your own address family
502 Bad Gateway with ECONNREFUSED in the proxy logReverse proxyThe upstream address you configured is wider than the upstream’s bind, or the upstream is not up yet

One last practical note on ordering, because it is where the hours go. Run the elapsed-time probe first: it splits refused from dropped in two seconds. If refused, run the listener decoder and compare the local address with the address the client printed. Only if those two agree, and the client still fails, do you start reading firewall rules and routing tables — at which point you are looking at a path problem, and the two linked posts are the right starting point.

Last verified: 2026-09-25 — every command, output line and version string in this article was executed on the host described above (Python 3.13.5, Node v26.5.1, curl 8.14.1, Linux 6.17 aarch64, bindv6only=0), with the two upstream configuration defaults verified against the Redis 7.2 and PostgreSQL 17 branches of their official repositories. The container packet-rewriting paragraph is labelled configuration-documented rather than measured, because this host has no Docker daemon.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.