Troubleshooting

Why does nginx log connect() failed to [::1] when proxy_pass says localhost?

localhost becomes two addresses, ::1 and 127.0.0.1, and a backend on only one of them fails on the other. Which builds see it, the 10 second lockout it adds, and the fix.

· 16 min read · How we verify this

Key points

  • proxy_pass http://localhost:8080 is resolved once at load into every address the C library returns for localhost, and nginx treats them as a round-robin group. When that list holds [::1] and the backend listens only on 127.0.0.1, the [::1] attempt fails with 111: Connection refused, nginx retries on 127.0.0.1, and the client still gets a 200.
  • Whether [::1] is in the list depends on the C library and the network namespace. Alpine (musl) includes it whenever loopback IPv6 works. Debian (glibc) drops it inside a default Docker container, but then returns 127.0.0.1 twice, so the group still has two members.
  • A two-member group is subject to max_fails=1 and fail_timeout=10s; a single address is not. So localhost turns a backend restart into 10 seconds of no live upstreams after the backend is already back, where 127.0.0.1 recovers on the next request.
  • The fix is to write 127.0.0.1 (or [::1]) in proxy_pass, or bind the backend to both loopback addresses. resolver ... ipv6=off does nothing for a literal name, and a variable proxy_pass with a resolver cannot resolve localhost at all.

nginx logs connect() failed (111: Connection refused) while connecting to upstream ... upstream: "http://[::1]:8080/" because localhost resolved to two addresses, the IPv6 loopback ::1 and the IPv4 loopback 127.0.0.1, and your backend listens on only one of them. nginx resolves a literal hostname in proxy_pass once, at configuration load, keeps every address the C library returns, and uses them as a round-robin group. The attempt on [::1] is refused, nginx moves to 127.0.0.1, and the client usually gets its 200 anyway. The fix is to write the address the backend actually listens on: proxy_pass http://127.0.0.1:8080;.

The line is not harmless noise, which is what most answers to this question conclude. Two addresses make a group, and a group is subject to max_fails and fail_timeout, so localhost adds a 10 second outage after every backend restart that 127.0.0.1 does not have. That cost appears on Debian builds too, which never log the [::1] line in a container, for a reason covered below.

What you seeWhat it meansDo this
[error] connect() failed (111: Connection refused) with upstream: "http://[::1]:PORT/...", request still succeedsBackend listens on 127.0.0.1 only; nginx tried ::1 firstproxy_pass http://127.0.0.1:PORT;
Same, with upstream: "http://127.0.0.1:PORT/..."Backend listens on ::1 onlyproxy_pass http://[::1]:PORT; or bind the backend to both
[warn] upstream server temporarily disabled while connecting to upstream right after itnginx marked that address down for fail_timeout (10s by default)Same fix; the warning goes with it
[error] no live upstreams while connecting to upstream with upstream: "http://localhost/..."Every address of localhost is marked down, so nginx did not even trySame fix; with one address nginx never marks it down
Access log $upstream_addr shows [::1]:8080, 127.0.0.1:8080 and $upstream_status shows 502, 200One request, two attempts: the first failed, the second answeredNothing is wrong with the response; fix the address

How the cases were reproduced#

Everything on this page comes from a test run on 15 October 2026 unless it is attributed to documentation or source code. The setup was one Docker network namespace shared by several containers, so they all saw the same loopback interface: a Python http.server backend bound to 127.0.0.1:8080 (and in a second round, to ::1:8080 only), then nginx 1.30.5 from the nginx:1.30-alpine image and nginx 1.30.6 from the Debian-based nginx:1.30 image, both with proxy_pass http://localhost:8080;. HAProxy 3.2.26, Caddy 2.11.7 and Node 24.21.0 were run against the same backend for comparison. The Docker engine was 28.1.1 on a default bridge network, which gives containers an IPv6 loopback address but no other IPv6 address.

The access log format used to see every attempt was:

nginx
log_format attempts '$status up=$upstream_addr ust=$upstream_status rt=$request_time';

$upstream_addr and $upstream_status list each attempt for the request, separated by commas, which is how a request that logs a 200 can still show a 502 inside it.

Which addresses localhost becomes, by C library#

nginx does not decide whether localhost includes ::1; the C library does. The nginx source shows how the name is resolved: ngx_inet_resolve_host() calls getaddrinfo() with AF_UNSPEC, SOCK_STREAM and the AI_ADDRCONFIG flag, and adds every IPv4 and IPv6 result to the list. The proxy module documentation states the consequence: "If a domain name resolves to several addresses, all of them will be used in a round-robin fashion."

AI_ADDRCONFIG is the flag that makes the answer depend on where nginx runs, and the two common C libraries read it differently:

  • glibc (Debian, Ubuntu, RHEL, the nginx:1.30 image). The Linux man page says that with AI_ADDRCONFIG, "IPv6 addresses are returned only if the local system has at least one IPv6 address configured. The loopback address is not considered for this case as valid as a configured address." A container on Docker's default bridge has only ::1, so glibc drops ::1. A normal Linux host has link-local fe80:: addresses on its interfaces, so glibc keeps it.
  • musl (Alpine, the nginx:*-alpine images). musl's source defines the condition "via ability to create a socket for the family plus routability of the loopback address for the family". Loopback IPv6 works in a default container, so musl keeps ::1.

What getent ahosts localhost and nginx's own $upstream_addr showed in each environment:

nginx buildWhere it runsAddresses for localhost, in orderTwo members, so max_fails applies?Backend on 127.0.0.1 onlyBackend on ::1 only
Alpine (musl), 1.30.5Default Docker bridge container::1, 127.0.0.1Yes200, [::1] error logged200, 127.0.0.1 error logged
Debian (glibc 2.41), 1.30.6Default Docker bridge container127.0.0.1, 127.0.0.1Yes200, nothing logged502 on every request
Debian (glibc 2.41), 1.30.6Host network, link-local IPv6 present::1, 127.0.0.1Yes200, [::1] error logged200, 127.0.0.1 error logged
Alpine (musl), 1.30.5Container with net.ipv6.conf.all.disable_ipv6=1127.0.0.1No200, nothing loggedNot tested

The duplicate in the second row is not a typo. Docker writes both 127.0.0.1 localhost and ::1 localhost ip6-localhost ip6-loopback into the container's /etc/hosts; glibc's getent ahosts localhost in that container printed 127.0.0.1 twice and no ::1, and nginx's $upstream_addr for a failed request read 127.0.0.1:8080, 127.0.0.1:8080. nginx builds a two-member group from it, which matters in the next section.

Why it mostly works, and the 10 second lockout it adds#

The request survives because of two defaults. proxy_next_upstream defaults to error timeout, and error covers "an error occurred while establishing a connection with the server". A refused connect is that error, so nginx tries the next address in the group. A POST is retried too: the documentation only stops non-idempotent requests from moving on "if a request has been sent to an upstream server", and nothing was sent over a refused connection. In the test a POST showed ust=502, 501, the second attempt reaching the backend.

The other default is the one that costs you. The upstream module documentation says max_fails is 1 and fail_timeout is 10 seconds: one failed attempt within 10 seconds marks the address unavailable for 10 seconds. That is what upstream server temporarily disabled while connecting to upstream announces at warn level. The same documentation carves out one exception: "If there is only a single server in a group, max_fails, fail_timeout and slow_start parameters are ignored, and such a server will never be considered unavailable." localhost never gets that exception in any of the environments above except the IPv6-disabled one.

With the backend running, the lockout is benign and even useful: [::1] is skipped for 10 seconds, so the error appears at most once per fail_timeout rather than on every request. With requests every two seconds, the test logged the [::1] error at 0 seconds and again at 16 seconds, the first time [::1] came round in the rotation after its 10 seconds expired. The upstream module documentation describes zone as the shared memory that keeps a group's run-time state "shared between worker processes"; an implicit group from proxy_pass has no zone, so each worker keeps its own failure state and logs its own line.

With the backend restarting, the lockout is an outage. The test stopped the backend, sent one request through each location, started the backend again, waited 1.5 seconds and sent another:

proxy_pass targetRequest while backend downRequest 1.5s after backend is backRequest 10.5s after
http://localhost:8080 (Alpine)502, both addresses tried and marked down502, no live upstreams200
http://localhost:8080 (Debian, container)502, 127.0.0.1 tried twice and marked down502, no live upstreams200
http://127.0.0.1:8080 (either build)502200200

Every deploy that restarts the backend therefore carries up to 10 extra seconds of 502s per worker if proxy_pass names localhost, and none if it names 127.0.0.1. The 502 is real, and in the access log it looks identical to a backend that is still down: only the no live upstreams line in the error log, with upstream: "http://localhost/..." and no address, gives it away. Reading 502, 503 and 504 by cause lists that line among the 502 signatures; this is one of the less obvious ways to produce it. The general mechanics of max_fails are in health checks and upstream failover.

The fix: write the address, not the name#

nginx
location / {
    proxy_pass http://127.0.0.1:8080;
}

An IP literal is not resolved, so there is exactly one address, so there is a single-server group that nginx never marks unavailable. If the backend listens on ::1 only, write proxy_pass http://[::1]:8080; with the brackets. The same applies inside an upstream block: server 127.0.0.1:8080; rather than server localhost:8080;.

If you want an upstream block for keepalive, keep the literal there:

nginx
upstream app {
    server 127.0.0.1:8080;
    keepalive 16;
}

Why keepalive is worth having for a local backend is covered in keep-alive and upstream connection pooling.

The alternative is to change the backend rather than the proxy and make it listen on both loopback addresses, or on the dual-stack wildcard. That fixes every client at once, not only nginx, but it widens what the backend accepts: a wildcard listener is reachable from outside the host unless something else blocks it. Check how your server binds. Node's server.listen(port) with no host is documented as accepting connections on the unspecified IPv6 address :: when IPv6 is available, and the same page says that in most operating systems this may also listen on 0.0.0.0. Python's http.server --bind 127.0.0.1, as in the test, listens on IPv4 only.

Fixes that do not work#

Several circulating answers to this error change something unrelated. Each was tried:

Suggested fixWhat happenedWhy
resolver 127.0.0.11 ipv6=off; with proxy_pass http://localhost:8080;[::1] error still loggedresolver is for names resolved at run time. A literal name in proxy_pass is resolved at load by getaddrinfo(), which never consults it
set $b localhost; proxy_pass http://$b:8080; with a resolver502 on every request: localhost could not be resolved (3: Host not found)The nginx resolver queries DNS servers and does not read /etc/hosts. Docker's DNS on 127.0.0.11 answered NXDOMAIN for localhost
server localhost:8080 resolve; in an upstream with a zone (1.27.3+)502, no live upstreams, after localhost could not be resolved at startSame resolver, same NXDOMAIN
Removing listen [::]:80 from the server blockThe test configuration had no listen [::] at all and still logged the [::1] errorThat is nginx's own listening socket for clients. It has nothing to do with the address nginx connects to upstream
Disabling IPv6 in the container (net.ipv6.conf.all.disable_ipv6=1)Error gone on AlpineWorks, because musl then drops ::1, but it changes the whole namespace to fix one line of configuration, and leaves the name dependent on the environment again

The resolve and variable forms are the right tools for a service name that changes address, such as a Docker container, and nginx 502 after a Docker container restart covers them. For a process on the same host, the address does not change, so there is nothing to re-resolve.

The same localhost in HAProxy, Caddy and Node#

Every client in front of a local backend inherits the same two addresses, and they disagree about what to do with them. Same namespace, same two backends:

ClientAddresses it uses for localhostBackend on 127.0.0.1 onlyBackend on ::1 only
nginx 1.30, AlpineAll, round robin, retry on failure200, error logged, 10s lockout risk200, error logged, 10s lockout risk
nginx 1.30, Debian, in container127.0.0.1 twice200502 every request
HAProxy 3.2.26, Alpine and Debian imagesFirst address only: ::1503, termination state SC, 3 retries, about 3s200
Caddy 2.11.7 (reverse_proxy localhost:8080)Tries each address200200
Node 24.21.0 fetch and http.get, AlpineTries each address200200
Node 24.21.0 fetch and http.get, Debian slim127.0.0.1 only (glibc drops ::1 in the container)200ECONNREFUSED 127.0.0.1:8080

HAProxy is the strictest. At startup, with the default init-addr last,libc, a hostname is resolved by the libc resolver, and the str2ip2() function in HAProxy's source copies only the first result into the server's address. HAProxy calls getaddrinfo() without AI_ADDRCONFIG, so even the Debian image got ::1 first, and the server pointed at ::1:8080 for its whole life. Each request then spent three connection retries on it, with the manual's turn-around timer of "min("timeout connect", one second)" between them, and failed with a 503:

text
503 SC srv=::1:8080 retries=3 conn_time=-1 total=3006

The fix is the same as for nginx (server s1 127.0.0.1:8080), or the manual's address prefix, which says an address after ipv4@ "is always considered as an IPv4 address". server s1 ipv4@localhost:8080 connected to 127.0.0.1:8080 with no retries. More on HAProxy server lines is in HAProxy configuration for HTTP reverse proxying.

Caddy never noticed, because Go's dialer handles multiple addresses itself. The Go net documentation says that when "the host resolves to multiple IP addresses, Dial will try each IP address in order until one succeeds", and that with both families present it starts the other family after FallbackDelay, with "a default delay of 300ms". Caddy as a reverse proxy inherits that, which is one reason Caddy users rarely search for this error.

Node changed twice. Node 17.0.0 made dns.lookup() return addresses in the resolver's order (verbatim) instead of IPv4 first, so a Node client calling a backend on 127.0.0.1 by the name localhost could connect to ::1 first and fail with ECONNREFUSED ::1. The autoSelectFamily option, documented as connecting "to all obtained IPv6 and IPv4 addresses, in sequence, until a connection is established", now defaults to true; net.getDefaultAutoSelectFamily() returned true on 24.21.0. The remaining failure in the table is glibc again: Node passes dns.ADDRCONFIG to the lookup on Linux, so in a Debian container ::1 is never in the list to try.

Find it in your own logs#

The access log shows it before the error log does, if $upstream_addr is logged. Search for requests with more than one attempt where the first is a loopback address:

bash
# Requests that needed a second attempt on a loopback upstream
grep -E 'up=\[::1\]:[0-9]+, 127\.0\.0\.1|up=127\.0\.0\.1:[0-9]+, \[::1\]' access.log | wc -l

# Requests refused outright because every address of the name was marked down
grep 'no live upstreams' error.log | grep -o 'upstream: "[^"]*"' | sort | uniq -c

A no live upstreams line whose upstream: value is a hostname with no port, such as "http://localhost/", means the group was built from a name that resolved to more than one address. Then confirm what the name resolves to in the environment nginx actually runs in, not on your workstation:

bash
docker exec <nginx-container> getent ahosts localhost | grep STREAM
nginx -T | grep -nE 'proxy_pass|server .*localhost'

More than one STREAM line means a group. If the debugging has to go further down the chain, the proxy debugging playbook covers the method.

Failure modes#

The [::1] error appears after switching the image from Debian to Alpine. Nothing in the configuration changed; the C library did. musl keeps ::1 for localhost where glibc in the same container drops it. Write 127.0.0.1.

502 on every request from a Debian container, backend definitely running. The backend listens on ::1 only, and glibc in a default Docker container gives nginx 127.0.0.1 twice for localhost, so nginx never tries ::1. ss -ltn inside the namespace shows [::1]:8080 as the only listener. Write proxy_pass http://[::1]:8080; or bind the backend to IPv4 as well.

About 10 seconds of 502 after each deploy, then fine. The localhost group was marked down while the backend restarted. Look for no live upstreams with upstream: "http://localhost/...". Write an IP literal, and the next request after the backend is up succeeds.

Works on the host, fails in the container (or the reverse). glibc keeps ::1 on a host with any non-loopback IPv6 address, link-local included, and drops it in a default container. The same configuration file produces a different upstream group in each place.

HAProxy 503 with SC and three retries, backend answering on 127.0.0.1. HAProxy resolved localhost once to ::1 and uses only that. Use 127.0.0.1 or ipv4@localhost.

Where this answer stops applying#

The table of addresses is for Linux with glibc 2.41 and musl as shipped in the tested images, on Docker 28.1.1's default bridge network. A Docker network with IPv6 enabled gives containers a non-loopback IPv6 address, and glibc then keeps ::1 as on the host. Hosts whose /etc/hosts maps localhost only to 127.0.0.1, or that resolve it through systemd's NSS modules, produce their own list; getent ahosts localhost is the authority for any given machine. Setting max_fails=0 on a server line removes the lockout (the documentation says "the zero value disables the accounting of attempts"), though not the extra failed attempt, and the nginx reverse proxy reference lists the upstream parameters. The same mechanism applies to any hostname with both A and AAAA records, but the error then depends on whether the IPv6 route exists, which was not tested here.

Frequently asked questions#

Why does nginx try [::1] when I wrote localhost?#

Because the C library returned ::1 as one of the addresses for localhost, and nginx uses every address a name resolves to as a round-robin group. nginx asks with getaddrinfo() and AI_ADDRCONFIG; musl keeps ::1 whenever IPv6 loopback works, and glibc keeps it whenever the system has any non-loopback IPv6 address.

Is the connect() failed [::1] error harmless if requests succeed?#

Not entirely. The request succeeds because nginx retries on 127.0.0.1, but two addresses form a group that max_fails=1 and fail_timeout=10s apply to. After a backend restart, nginx can return no live upstreams 502s for up to 10 seconds even though the backend is already back.

Should I use localhost or 127.0.0.1 in proxy_pass?#

Use 127.0.0.1, or [::1] if the backend listens on IPv6 loopback only. An IP literal is a single address, nginx never marks a single-server group unavailable, and the result no longer depends on which C library or network namespace nginx runs in.

Does resolver ipv6=off fix it?#

No. resolver applies to names nginx resolves at run time, through a variable proxy_pass or the resolve parameter. A literal localhost in proxy_pass is resolved once at load through the C library, which ignores the resolver directive. And the run-time resolver queries DNS rather than /etc/hosts, so it cannot resolve localhost at all.

Why does HAProxy return 503 for localhost when nginx works?#

HAProxy resolves a server's hostname once at startup and keeps only the first address, which was ::1 in both the Alpine and Debian images tested. nginx keeps every address and moves to the next one. With a backend on 127.0.0.1 only, HAProxy spends its retries on ::1 and logs SC. Use 127.0.0.1 or ipv4@localhost.

Why does Node.js show ECONNREFUSED ::1?#

Since Node 17.0.0, dns.lookup() keeps the resolver's order instead of putting IPv4 first, so localhost often resolves to ::1 first. Node 24 tries every address by default through autoSelectFamily, so there the error means that option was turned off, or that every address Node was given failed.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. nginx ngx_http_proxy_module (proxy_pass, proxy_next_upstream)
  2. nginx ngx_http_upstream_module (server, max_fails, fail_timeout, zone)
  3. nginx ngx_http_core_module, resolver
  4. nginx source, ngx_inet_resolve_host (getaddrinfo with AI_ADDRCONFIG)
  5. getaddrinfo(3), Linux man-pages (AI_ADDRCONFIG)
  6. musl libc source, getaddrinfo.c (AI_ADDRCONFIG via loopback routability)
  7. HAProxy 3.2 configuration manual (address prefixes, init-addr, retries)
  8. HAProxy source, str2ip2 in tools.c (first address only)
  9. Node.js net documentation (autoSelectFamily)
  10. Node.js dns documentation (lookup order, verbatim default)
  11. Go net package (Dial over multiple addresses, Dialer.FallbackDelay)

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in troubleshooting proxies#