Guide
Why does Nginx Proxy Manager return 502 Bad Gateway?
Nginx Proxy Manager tried to reach the forward host and failed, and it wrote the exact reason to that host's own error log. We broke a test setup thirteen ways: the four log lines below cover every 502 we produced, and two of the causes most threads blame turned out not to give a 502 at all.
Why does Nginx Proxy Manager return 502 Bad Gateway for a Docker container?
Because NPM could not get a response from the forward host and port you entered. Read /data/logs/proxy-host-N_error.log inside the NPM container. Connection refused means nothing listens on that port (wrong port, or the app is bound to 127.0.0.1). could not be resolved means the container name is unknown on NPM's networks. No route to host means a stale IP. wrong version number means the scheme is https for a plain HTTP port. A slow app gives 504, not 502.
Because nginx inside Nginx Proxy Manager tried to reach the forward host and port you typed and got no usable answer. NPM does not show the reason in the browser, but it writes it to the host's own error log, /data/logs/proxy-host-N_error.log, and that one line separates four different faults with four different fixes:
docker exec npm tail -n 3 /data/logs/proxy-host-*_error.logReplace npm with your container's name. If you have many hosts, docker exec npm grep -l app.example.com /data/nginx/proxy_host/*.conf names the file: each generated config starts with its domain in a comment, and the number in the filename is N.
The four log lines, measured#
We ran NPM 2.16.0 on Docker Engine Engine 28.1.1, on a user-defined network, and pointed thirteen proxy hosts at backends broken in different ways. Every failure landed in one of these rows:
| Error log says | Status, time | What it means | Fix |
|---|---|---|---|
connect() failed (111: Connection refused) while connecting to upstream | 502, 2 ms | The address answered, nothing listens on that port | Correct the port, or make the app listen on 0.0.0.0 |
app-b could not be resolved (3: Host not found) | 502, under 70 ms | Docker's DNS does not know that name on NPM's networks | Put both on one network, fix the name |
connect() failed (113: No route to host) while connecting to upstream | 502, 3.07 s | No machine has that IP on the subnet | Use the container name, not an IP |
SSL_do_handshake() failed (SSL: error:0A00010B:SSL routines::wrong version number) while SSL handshaking to upstream | 502, 2 ms | Scheme is https, the port speaks plain HTTP | Set the scheme to http |
upstream timed out (110: Connection timed out) while connecting to upstream | 504, 90 s | Packets dropped: another Docker network by IP, or a firewalled host port | Shared network; see below |
upstream timed out (110: Connection timed out) while reading response header from upstream | 504, 90 s | App accepted the request and took more than 90 s | Raise proxy_read_timeout for that host |
| Nothing in the error log | 400, 5 ms | Scheme is http, the port speaks TLS | Set the scheme to https |
The access log next to it confirms which upstream was tried. A refused request read 502 502 - GET http port.test "/" ... [Sent-to app-ok].
Two answers circulate, and one of them is the wrong error#
Most threads say a 502 means the containers are on different networks, and fixing the network is the first thing to try. That is one of four causes, and the log tells you in a second whether it is yours. If the line says Connection refused, the network is fine: the name resolved and the container's kernel answered. Changing networks will not help.
The second common answer is that a slow app or a timeout causes the 502. It does not. Timeouts produce 504 Gateway Timeout, and on NPM they take 90 seconds, not nginx's documented 60. NPM's own nginx.conf sets proxy_connect_timeout, proxy_send_timeout and proxy_read_timeout to 90s. Our app that answered after 75 seconds got a 200; the one that answered after 100 got a 504 at exactly 90.0 s. If you see 502, stop looking at timeouts.
And sending plain HTTP to an HTTPS port, the usual scheme mismatch, is not a 502 either. The backend nginx returned its own 400 The plain HTTP request was sent to HTTPS port, and NPM passed it through. The reverse mismatch is the 502.
Connection refused: the right container, the wrong port#
Error 111 arrives almost instantly because something replied: the target's kernel, saying no process is listening there. We produced it three ways.
The published port instead of the container port. When NPM reaches an app by container name, it connects to the container's own address, so port mappings in the app's compose file do not apply. An nginx container that listens on 80 and is published as 8080:80 must be entered as port 80. Entering 8080 gave upstream: "http://10.201.13.3:8080/" and a refusal.
The app listens on 127.0.0.1 inside its container. We configured a backend with listen 127.0.0.1:80. It answered curl from inside its own container and refused NPM, with the same 111 line. Look for a HOST, BIND or LISTEN setting in the app and set it to 0.0.0.0.
127.0.0.1 as the forward host. Inside the NPM container, loopback is NPM. The connection to 127.0.0.1:8080 was refused in under a millisecond.
An app that is still starting gives the same line for as long as its listener is not up, so check docker logs on the app before you change the port.
Could not be resolved: the name does not exist where NPM looks#
NPM's generated config does not hard-code the upstream. Every proxy host sets $server and $port and then runs proxy_pass $forward_scheme://$server:$port$request_uri. nginx's documentation says that when proxy_pass contains variables, a domain name is resolved with the configured resolver, and NPM generates that line at boot: on our network it read resolver 127.0.0.11 valid=10s;. That address is Docker's embedded DNS server, which answers container names only for networks NPM is attached to. Why a Docker container cannot resolve DNS covers how that server works.
Four mistakes gave us the identical line, each in under 70 ms:
- The app's container is on another network. Docker's bridge documentation is explicit that user-defined bridges provide name resolution between containers on that network, and that the default bridge provides none at all.
- The container is stopped. A stopped container's name disappears from Docker DNS.
- A typo, such as
app_okforapp-ok. Compose service names and container names are different strings; NPM's documentation says the service name is the hostname, so make service names unique on a shared network. localhost. nginx's resolver asks DNS only and never reads/etc/hosts, so it loggedlocalhost could not be resolved (3: Host not found).
The fix NPM's documentation recommends is one external network that NPM and every app join, then the app's name as the forward host and no published port on the app at all:
networks:
default:
external: true
name: proxyOr attach a running container without editing anything: docker network connect proxy <app>. Because the lookup happens per request, nothing needs a restart. After docker network connect, the first request we sent returned 200.
No route to host and timeouts: the address is real but nothing gets there#
Error 113 took 3.07 seconds, the time the kernel spends asking the subnet who has the address before giving up. We got it by pointing NPM at an IP on its own network with no container behind it. That is what happens when a host was entered by IP and the container was later recreated with a different one. Docker assigns addresses at creation, so IPs are not stable; names are.
Pointing at the real IP of a container on a different network gave the timeout row instead. Docker's documentation says containers on different bridge networks can only communicate through published ports, and the rules it installs drop the traffic silently, so nginx waited the full 90 seconds and returned 504. Forwarding to the host's gateway address on a port the host had published only on 127.0.0.1 timed out the same way. A host-side service is its own problem, with its own firewall layer, and Reaching a host service from a container goes through it.
If the timeout is the read kind and the app is genuinely slow (a large upload being processed, a report being built), raise the limit for that host only. NPM's per-host Advanced tab is injected into the generated server block, so proxy_read_timeout 300s; there applies to every location of that host. nginx counts that timeout between two successive reads, not for the whole response, so a slow stream that keeps sending data never needs it.
Wrong version number: https to a plain port#
SSL_do_handshake() failed ... wrong version number means NPM opened TLS and the backend answered in plain text. The fix is the scheme dropdown, http. Most apps behind a proxy should be plain HTTP on the inside; TLS is NPM's job, which Reverse proxy and TLS explains.
The opposite case does work: https to a backend with a self-signed certificate returned 200, because nginx's proxy_ssl_verify is off by default and NPM leaves it off. That is convenient for apps that only speak HTTPS, and it also means NPM would not notice an impostor on that hop.
The one-command test#
Run the request NPM makes, from inside NPM, with the exact host, port and scheme from the proxy host form:
docker exec npm curl -sS -o /dev/null -w '%{http_code}\n' --max-time 10 http://app-ok:80The image ships curl at /usr/bin/curl. Each failure maps onto the log:
| curl prints | Same as log line |
|---|---|
curl: (7) Failed to connect to app-ok port 8080 after 0 ms: Could not connect to server | 111, refused |
curl: (6) Could not resolve host: app-b | could not be resolved |
curl: (7) Failed to connect to 10.201.13.250 port 80 after 3066 ms | 113, no route |
curl: (28) Connection timed out after 10002 milliseconds | 110, timed out |
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number | SSL handshake |
200 | The path works; recheck the proxy host form |
One disagreement: curl reads /etc/hosts, nginx's resolver does not. For localhost, curl resolved the name and got refused, while nginx failed to resolve it. If curl says 200 and NPM still says 502, compare the two names character by character.
When this answer stops applying#
- Other network setups. We tested only NPM on a user-defined bridge. NPM with
network_mode: host, macvlan, Unraid custom networks, Podman and rootless Docker were not tested. - Other versions. The 90 second timeouts and the resolver line are from the 2.16.0 image. Check
grep timeout /etc/nginx/nginx.confinside yours. - A 502 the app sends itself. Apps with their own internal proxy, such as GitLab's bundled nginx while it boots, return 502 on their own. NPM then connected fine and logs nothing, so an empty error log next to a 502 points inside the app.
What to do next#
Read the error log line, match it to the table, then confirm with the curl test before changing anything. If most of your fixes turn out to be networks and container names, that is the part of NPM that Caddy vs NPM weighs against Caddy, whose config file sits beside your compose file. If the app now loads but shows the proxy's address as every visitor, Why container logs show 172.17.0.1, not the client IP is the next problem.
Questions#
Where are the Nginx Proxy Manager error logs?
Inside the container at /data/logs/, one pair of files per proxy host: proxy-host-N_access.log and proxy-host-N_error.log, where N is the host's database ID. docker logs shows the NPM backend, not these. To find N for a domain, run docker exec <npm> grep -l app.example.com /data/nginx/proxy_host/*.conf: each generated file starts with the domain name in a comment. The access log also records [Sent-to ...], the forward host it used.
Should the forward port be the published port or the container port?
The container port, when NPM reaches the app by container name on a shared network. The connection goes straight to the container's address, so a mapping like 8081:80 is irrelevant and the forward port is 80. Entering 8081 gave us connect() failed (111: Connection refused) in 2 ms. The published port only matters if you forward to the host's address instead.
Do I need to restart Nginx Proxy Manager after fixing the network?
No. NPM's generated config passes the upstream through variables, so nginx looks the name up at request time through Docker's DNS at 127.0.0.11, with answers cached for 10 seconds. After docker network connect, and after starting a stopped container, the very next request returned 200 in our tests. It is also why NPM starts happily with a forward host that does not exist.
Can I use localhost as the forward host in Nginx Proxy Manager?
No. Inside the NPM container, localhost is NPM itself, not your server. On 2.16.0 it fails before that point: nginx's resolver does not read /etc/hosts, so the log says localhost could not be resolved (3: Host not found), while curl http://localhost in the same container resolves it and gets refused. 127.0.0.1 is refused too. Use the app's container name, or the host's address for a service on the host.
Does Nginx Proxy Manager check the backend's TLS certificate?
Not by default. A forward scheme of https to a backend with a self-signed certificate returned 200 in our test, because nginx's proxy_ssl_verify is off by default and NPM's templates do not turn it on. What fails is https to a port that speaks plain HTTP, which logs SSL_do_handshake() failed (SSL: error:0A00010B:SSL routines::wrong version number).
Sources#
- Nginx Proxy Manager docs, advanced configuration (custom Docker network, service name as hostname)
- nginx, ngx_http_proxy_module (proxy_pass with variables, timeouts, proxy_ssl_verify)
- nginx, ngx_http_core_module, resolver directive
- Docker docs, bridge network driver (DNS on user-defined bridges, isolation between networks)
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.