Why does nginx in Docker return 502 when proxy_pass points at localhost?
Inside a container, localhost is the container itself. What to put in proxy_pass for a backend on the host or in another container, and the bind address each needs.
Key points
- Inside a container,
127.0.0.1andlocalhostare the container's own loopback, not the host's.proxy_pass http://127.0.0.1:8080from an nginx container reaches only a process in that same container, so anything else getsconnect() failed (111: Connection refused)and a 502. - For a backend on a Linux host, write
proxy_pass http://host.docker.internal:8080;and add--add-host host.docker.internal:host-gateway(Compose:extra_hosts). Docker Engine does not define the name by itself: without the flag nginx exits withhost not found in upstream "host.docker.internal". host-gatewayis the host's bridge address (10.201.0.1in the test), so the host service must listen on that address or on0.0.0.0. A host app bound to127.0.0.1refuses the connection: still error 111, now with the gateway address in the log.- A host firewall that drops new connections turns the same mistake into a 504,
upstream timed out (110), afterproxy_connect_timeout(60s by default). From a Compose network the packets arrive on that network'sbr-interface, not ondocker0. - For a backend in another container, put both on one user-defined network (any Compose project does this) and use the service name:
proxy_pass http://api:8000;. The backend must listen on0.0.0.0inside its container.
nginx in a Docker container returns 502 for proxy_pass http://localhost:8080 because inside the container localhost and 127.0.0.1 are the container's own loopback interface, and nothing listens on port 8080 there. Every container has its own network namespace, so the backend running on the host, or in a second container, is somewhere else, and the connection is refused at once: connect() failed (111: Connection refused) while connecting to upstream. The fix depends on where the backend lives. Another container: put both on one user-defined network and use its service name. The host, on Linux: proxy_pass http://host.docker.internal:8080; with --add-host host.docker.internal:host-gateway, and the host app must listen on more than 127.0.0.1.
Two answers circulate for the host case, and each is half right. "Use host.docker.internal" is correct, but on Linux the name does not exist until you add it, and it leads to a second refusal when the host app is bound to loopback. "Use network_mode: host" also works, but it removes the container's network isolation, ignores every -p mapping, and puts nginx on the host's port 80. The table below gives the right combination for each case, all of it tested.
Where the backend lives decides what to write#
| Backend runs | Write in proxy_pass | Docker flag or Compose key | The backend must listen on | Result when the bind address is wrong |
|---|---|---|---|---|
| In the same container (or the same network namespace) | http://127.0.0.1:PORT | none, or --network container:NAME | 127.0.0.1 is enough | n/a |
| In another container, same user-defined network | http://SERVICE:PORT | both on one network (Compose does this) | 0.0.0.0 inside its container | 111 to the container's address |
In another container, published with -p PORT:PORT | http://host.docker.internal:PORT | --add-host host.docker.internal:host-gateway | Published on all interfaces, not 127.0.0.1:PORT:PORT | 111 to the gateway address |
| On a Linux host (Docker Engine) | http://host.docker.internal:PORT | --add-host host.docker.internal:host-gateway, or extra_hosts | 0.0.0.0 or the bridge gateway address | 111 to the gateway address; 504 if a firewall drops it |
| On the host, Docker Desktop (Mac, Windows) | http://host.docker.internal:PORT | none: Desktop resolves the name | Not tested here; see below | Not tested here |
| On a Linux host, nginx with host networking | http://127.0.0.1:PORT | --network host, network_mode: host | 127.0.0.1 is enough | n/a, but nginx shares the host's ports |
Every nginx row in that table was produced from a container and checked against the error log line in the sections below, except the Docker Desktop row, which comes from Docker's documentation.
How the cases were reproduced#
Everything on this page was observed on 23 October 2026 unless it is attributed to documentation. nginx 1.30.6 from the nginx:1.30-alpine image and nginx 1.27.5 from nginx:1.27-alpine ran on Docker Engine 28.1.1 and Compose 2.35.1 on a Linux host (kernel 5.4). Host backends were Python http.server processes started with explicit --bind addresses; container backends were the same server in python:3.13-alpine. Each case was a fresh nginx container with one proxy_pass line, proxy_connect_timeout 5s (so a dropped connection gave up in 5 seconds instead of 60), and an access log that records every upstream attempt:
log_format t '$status up=$upstream_addr ust=$upstream_status';The host had a Plesk firewall whose INPUT chain ends in DROP and accepts TCP ports 49152 to 65535. That made it possible to test a host port the firewall allows and one it drops without changing the firewall. Docker Desktop was not available, so nothing on this page was run on it.
Why 127.0.0.1 is the wrong machine#
The bridge network driver is, in Docker's words, "the default network driver", and each container on it gets its own network stack with its own loopback interface. 127.0.0.1 inside the nginx container is that container. With a host backend bound to 127.0.0.1:50080 and nginx on a user-defined network:
[error] connect() failed (111: Connection refused) while connecting to upstream, ...
upstream: "http://127.0.0.1:50080/"localhost fails the same way with two lines instead of one. The Alpine image resolves localhost to ::1 and 127.0.0.1, nginx tries both, and both are refused:
[error] connect() failed (111: Connection refused) ... upstream: "http://[::1]:50080/"
[warn] upstream server temporarily disabled while connecting to upstream ... upstream: "http://[::1]:50080/"
[error] connect() failed (111: Connection refused) ... upstream: "http://127.0.0.1:50080/"
[warn] upstream server temporarily disabled while connecting to upstream ... upstream: "http://127.0.0.1:50080/"The access log line for that request read 502 up=[::1]:50080, 127.0.0.1:50080 ust=502, 502. If you see the [::1] line while the backend really is in the same container and works on 127.0.0.1, that is a different problem with the same error text, covered in why nginx tries ::1 when proxy_pass says localhost.
Error 111 means something answered with a reset: there is a reachable machine at that address and nothing listening on the port. That is how to tell the cases apart. When the upstream address in the log is 127.0.0.1 or [::1] and nginx runs in a container, the backend is not in that container. When the address is the gateway (below) and you still get 111, the backend is on the host but bound to the wrong address.
A backend on a Linux host: host.docker.internal and host-gateway#
On Docker Engine for Linux, host.docker.internal is not a built-in name. Docker's docker run reference says --add-host adds entries to the container's /etc/hosts, that it "supports a special host-gateway value that resolves to the internal IP address of the host", and that "it's conventional to use host.docker.internal as the hostname referring to host-gateway". It also says "Docker Desktop automatically resolves this hostname", which is why answers written on a Mac leave the flag out.
Without the flag, nginx does not start. It resolves literal names in proxy_pass when it loads the configuration, and the container exits:
[emerg] 1#1: host not found in upstream "host.docker.internal" in /etc/nginx/nginx.conf:8With it:
docker run -d --name proxy -p 80:80 \
--add-host host.docker.internal:host-gateway \
-v ./nginx.conf:/etc/nginx/nginx.conf:ro nginx:1.30-alpinelocation / {
proxy_pass http://host.docker.internal:8080;
}The Compose equivalent is extra_hosts. The Compose reference documents the HOSTNAME=IP form, with : also accepted:
services:
proxy:
image: nginx:1.27-alpine
ports: ["80:80"]
extra_hosts:
- "host.docker.internal:host-gateway"In both forms the container's /etc/hosts got the line 10.201.0.1 host.docker.internal. 10.201.0.1 is the address of docker0, the default bridge, on this host; yours is whatever ip -4 addr show docker0 prints. The Compose service ran on its own project network with a different gateway and still got the docker0 address, and it worked: the host accepts a connection to any of its own addresses arriving from any bridge.
The host app bound to 127.0.0.1#
This is the second wall, and it produces the same error number as the first. With host-gateway in place and the host backend bound to 127.0.0.1:50080:
[error] connect() failed (111: Connection refused) while connecting to upstream, ...
upstream: "http://10.201.0.1:50080/"The connection now reaches the host, arriving at 10.201.0.1, and nothing listens there because the app accepts connections only on loopback. The same server bound to 0.0.0.0:50081 answered 200, and one bound only to 10.201.0.1:50082 also answered 200, from the default bridge and from a user-defined network alike.
| Host app listens on | From nginx via host.docker.internal | Reachable from other machines? |
|---|---|---|
127.0.0.1:PORT | 502, error 111 to the gateway address | No |
0.0.0.0:PORT | 200 | Yes, unless a firewall blocks it |
The gateway address only (10.201.0.1:PORT here) | 200 | No |
Binding to the gateway address is the narrowest choice: containers reach the app and the network does not. Its cost is that the address is specific to the host, and it must exist when the app starts, so start the app after Docker. Many development servers bind to 127.0.0.1 by default, so this is the usual reason the host case still fails after the --add-host fix.
A backend in another container: use the service name#
For container to container traffic, the host is the wrong path. Docker's networking overview says containers on the default bridge "cannot refer to each other by name", while on a user-defined network they "can communicate with each other using container IP addresses or container names", through the embedded DNS server at 127.0.0.11. Compose creates a user-defined network for each project, so in Compose this is already true:
services:
api:
image: myapp
# listens on 0.0.0.0:8000 inside its container
proxy:
image: nginx:1.30-alpine
ports: ["80:80"]location / {
proxy_pass http://api:8000;
}No ports: is needed on api: Docker says "all ports of containers on bridge networks are accessible from the Docker host and other containers connected to the same network". What each variation did:
| nginx network | proxy_pass | Backend listens on | Result |
|---|---|---|---|
| Same user-defined network | http://api:8000 | 0.0.0.0:8000 | 200 |
| Same user-defined network | http://localhost:8000 | 0.0.0.0:8000 | 502, 111 to [::1] and 127.0.0.1 |
| Same user-defined network | http://apilo:8000 | 127.0.0.1:8000 | 502, 111 to the container's address |
Default bridge (no --network) | http://api:8000 | 0.0.0.0:8000 | nginx exits: host not found in upstream "api" |
| A different user-defined network | http://api:8000 | 0.0.0.0:8000 | nginx exits: host not found in upstream "api" |
Other network, backend published -p 50083:8000 | http://host.docker.internal:50083 | 0.0.0.0:8000 | 200, through the host |
Other network, backend published -p 127.0.0.1:50084:8000 | http://host.docker.internal:50084 | 0.0.0.0:8000 | 502, 111 to the gateway address |
--network container:apilo (shared namespace) | http://127.0.0.1:8000 | 127.0.0.1:8000 | 200 |
The third row is the container version of the host bind problem: a framework started with its loopback default inside a container cannot be reached from any other container. The fix is in the app's start command (--host 0.0.0.0, --bind 0.0.0.0:8000, or the framework's equivalent), not in nginx.
The fourth and fifth rows fail at start, not at request time, which is useful: an nginx container that exits with host not found in upstream is on the wrong network. Attach it to the backend's network (docker network connect, or list the network under the service in Compose) rather than going through the host.
The last row is the only case where 127.0.0.1 in proxy_pass is right. A container started with --network container:NAME (Compose: network_mode: "service:NAME") shares the other container's network namespace and therefore its loopback. That is the sidecar layout, and the backend can stay on 127.0.0.1.
network_mode: host, and what it costs#
Host networking removes the problem by removing the container's own network stack. The Docker host driver page says the container's "network stack isn't isolated from the Docker host", and with it 127.0.0.1 in nginx is the host's loopback. With --network host, proxy_pass http://127.0.0.1:50080 reached the host app bound to 127.0.0.1 and answered 200.
It changes three other things, and they are why it is a poor default for a reverse proxy:
- Published ports are ignored. The documentation says "the
-p,--publish,-P, and--publish-alloptions are ignored", and Docker printedWARNING: Published ports are discarded when using host network mode. nginx listens directly on the host's ports. - nginx collides with whatever already holds the port. On the test host the first attempt failed with
[emerg] bind() to 0.0.0.0:80 failed (98: Address in use)repeated, because the host's own web server held port 80. Changinglistento a free port fixed it. - Container names stop resolving the usual way. A host network container is on no bridge network, so the service name method above is gone for anything else nginx proxies to.
Host networking is supported on Docker Engine on Linux, and on Docker Desktop 4.34 and later only after signing in and checking Enable host networking in Settings, according to the same page. It works only with Linux containers, and the page lists limits on Desktop, including that "processes inside the container cannot bind to the IP addresses of the host".
Docker Desktop: what changes#
On Docker Desktop for Mac and Windows, Docker's networking how-to says host.docker.internal "resolves to the internal IP address of your host", and adds gateway.docker.internal for "the gateway IP of the Docker VM". It gives the reason for using a name: "the host has a changing IP address, or none if you have no network access". Its example starts python -m http.server 8000 on the host, which listens on all interfaces, and fetches http://host.docker.internal:8000 from an Alpine container.
So the same proxy_pass http://host.docker.internal:PORT; works on Desktop without --add-host. The extra_hosts line is what Linux needs. Desktop's handling of a host app bound only to 127.0.0.1 was not tested for this page and its documentation does not state it, so the bind address advice above is for Docker Engine on Linux. A host app on 0.0.0.0 matches Docker's own example on Desktop.
Failure modes#
502, connect() failed (111) with upstream: "http://127.0.0.1:PORT/" from a containerised nginx. The backend is not in nginx's container. Use the service name or host.docker.internal, per the first table.
nginx container exits at start with host not found in upstream "host.docker.internal". Docker Engine on Linux, no --add-host. Add --add-host host.docker.internal:host-gateway or the extra_hosts entry.
502, error 111 with the gateway address (the docker0 address, 10.201.0.1 on the test host) in upstream:. The host app, or a port published as 127.0.0.1:PORT:PORT, accepts only loopback connections. Bind it to 0.0.0.0 or the gateway address, or publish on all interfaces.
504, upstream timed out (110) while connecting to upstream with the gateway address. A host firewall drops the connection. Allow the port from the bridge interfaces, br-* as well as docker0.
nginx container exits with host not found in upstream "api". nginx is on the default bridge or on another network. Attach it to the backend's user-defined network.
502, error 111 to a container address after a backend redeploy, though it worked before. If the address in the log is not the container's current address, this is the stale address problem in nginx 502 after a Docker container restart. If it is the current address, the backend is listening on 127.0.0.1 inside its container.
bind() to 0.0.0.0:80 failed (98: Address in use) after switching to host networking. The host already uses that port. Pick another listen port or stop the host service.
For any 502 that matches none of these, start from the status itself in 502 vs 503 vs 504, then bisect the chain hop by hop with the proxy debugging playbook. A request sent with wget from inside the nginx container (docker exec proxy wget -qO- http://host.docker.internal:8080/) tests the same path nginx uses, without nginx.
Where this answer stops applying#
The results are for Docker Engine 28.1.1 on Linux with IPv4 bridge networks, Compose 2.35.1, and nginx 1.30.6 and 1.27.5 on Alpine; the two nginx versions behaved identically. IPv6 bridge networks, rootless Docker, Podman and Kubernetes were not tested. The firewall behaviour depends entirely on the host's rules, and the 504 above is what a dropping rule produces, not what every host does. Docker Desktop results come from its documentation only. General reverse proxy settings for the working configuration are in nginx as a reverse proxy, and proxies that read container addresses from the Docker API instead of DNS, such as Traefik with its Docker provider, avoid hand-written upstream addresses altogether.
Frequently asked questions#
Why can't nginx in Docker reach localhost?#
Because localhost inside a container is the container's own loopback interface, not the host's. A backend running on the host or in another container is not listening there, so nginx gets connect() failed (111: Connection refused) and returns 502.
Does host.docker.internal work on Linux?#
Only if you add it. Docker Engine on Linux does not define the name; --add-host host.docker.internal:host-gateway or extra_hosts: ["host.docker.internal:host-gateway"] in Compose maps it to the host's bridge address. Docker Desktop resolves it automatically.
Why do I still get connection refused after adding host-gateway?#
Because the host app listens only on 127.0.0.1. The connection from the container arrives at the host's docker0 address, where nothing listens. Bind the app to 0.0.0.0 or to the bridge address.
What should proxy_pass be for another container in Docker Compose?#
The service name and the container's internal port, for example proxy_pass http://api:8000;. Compose puts every service on one user-defined network with DNS, so no published port is needed, but the backend must listen on 0.0.0.0 inside its container.
Should I use network_mode: host for nginx?#
Only if you accept its costs. It makes 127.0.0.1 the host's loopback, but Docker ignores every published port, nginx binds directly to host ports and can collide with the host's web server, and service names of other containers are no longer the natural way to reach them.
Why does nginx return 504 instead of 502 when proxying to the host?#
Because a host firewall is dropping the connection rather than refusing it. nginx waits for proxy_connect_timeout (60s by default) and logs upstream timed out (110) while connecting to upstream. Allow the port from the Docker bridge interfaces, including the br- interfaces of Compose networks.
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.
- Docker CLI reference, docker container run (--add-host, host-gateway)
- Docker Engine networking overview (drivers, default bridge, embedded DNS)
- Docker Engine host network driver
- Docker Desktop networking how-tos (host.docker.internal)
- Docker Desktop networking
- Compose file reference, services (extra_hosts, network_mode)
- nginx ngx_http_proxy_module (proxy_pass, proxy_connect_timeout)
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#
- Cntlm vs Px: which one for an NTLM or Kerberos proxy?
- Corporate proxies and developer tooling
- Access-Control-Allow-Origin multiple values: the fix
- curl through a proxy
- Header and body size limits at the proxy
- HTTPS_PROXY: http:// or https://? Tested in 12 clients
- netsh winhttp vs Windows proxy settings: who reads which?
- nginx 413 Request Entity Too Large: find the limit
- nginx 499: what it means, and its ALB, HAProxy, Envoy names
- nginx drops headers with underscores: why, and the fix
- nginx 400 plain HTTP request sent to HTTPS port: fix
- nginx proxy_cache always MISS: every cause, tested
- nginx SSL handshaking to upstream errors: fix each one
- nginx cuts off large downloads: find which of 5 causes
- nginx upstream sent no valid HTTP/1.0 header: the fix
- nginx worker_connections: the right number for a proxy
- The no_proxy environment variable
- A proxy debugging playbook