Why does Nginx Proxy Manager show 502 Bad Gateway for a proxy host?
Each NPM proxy host field mapped to the nginx line it generates and the error log line a wrong value leaves. Tested on NPM 2.16.0: localhost, ports, networks, scheme.
Key points
- Read
/data/logs/proxy-host-<id>_error.loginside the NPM container first. The<id>is the number in the generated file/data/nginx/proxy_host/<id>.conf, and each wrong form value leaves its own line there. - Forward Hostname
localhostdoes not even reach NPM's own loopback. NPM passes the name through a variable, so nginx asks Docker's DNS (127.0.0.11), which has no answer:localhost could not be resolved (3: Host not found).127.0.0.1givesconnect() failed (111: Connection refused)instead. Both are the NPM container itself, never your app. host.docker.internalfails the same way even withextra_hosts: host-gatewayon the NPM service, because nginx's resolver does not read/etc/hosts. Put the bridge gateway address in the Forward Hostname field instead.- Two settings people blame are not 502s at all. Websockets Support off gave 426 from the backend, and Scheme
httpto an HTTPS-only nginx backend gave 400 The plain HTTP request was sent to HTTPS port. Schemehttpsto a plain HTTP backend is a 502:SSL_do_handshake() failed ... wrong version number. - NPM's main location re-resolves the name every 10 seconds, so a recreated container recovers by itself. It does not if any Custom Location, in any proxy host, names the same host and port: that literal
proxy_passpins the old address for every host until NPM reloads nginx.
Nginx Proxy Manager shows 502 Bad Gateway when the nginx it generates cannot get a response from the address in the proxy host's Details tab, and the exact reason is written in one file: /data/logs/proxy-host-<id>_error.log inside the NPM container. Six causes account for almost every case. Forward Hostname is localhost or 127.0.0.1, which is the NPM container itself. Forward Port is the port published on the host, while the hostname is a container on NPM's network. NPM and the backend do not share a Docker network, so the name does not resolve. Scheme is https and the backend speaks plain HTTP. Forward Hostname is an IP address the container has since lost. Or a Custom Location pins an old address. Each one leaves a different error line, and the table below maps every field to the line it produces.
Two answers circulate for this error: "turn on Websockets Support" and "change Scheme to https". In testing, neither switch cured a 502. Websockets Support off produced a 426 from the backend, not a 502. Scheme http against an HTTPS-only nginx backend produced a 400. The switch that does create a 502 is Scheme https against a plain HTTP app, which is the opposite of the usual advice.
Each form field, the line it generates, and the error a wrong value leaves#
NPM writes one nginx file per proxy host from a template, backend/templates/proxy_host.conf. The Details tab becomes three variables at the top of the server block, and the default location / builds proxy_pass from them:
server {
set $forward_scheme http; # Scheme
set $server "app"; # Forward Hostname / IP
set $port 8000; # Forward Port
...
location / {
include conf.d/include/proxy.conf;
# which contains:
# proxy_pass $forward_scheme://$server:$port$request_uri;
}
}That proxy_pass contains variables, and it decides most of the behaviour on this page. The nginx documentation says that when proxy_pass holds variables and the address is a domain name, "the name is searched among the described server groups, and, if not found, is determined using a resolver". NPM generates that resolver at startup from the container's /etc/resolv.conf; on a Docker network the generated line was resolver 127.0.0.11 valid=10s;.
| UI field and value | Generated nginx | Error log line (proxy-host-<id>_error.log) | Client sees |
|---|---|---|---|
Forward Hostname localhost | set $server "localhost"; | localhost could not be resolved (3: Host not found) | 502 |
Forward Hostname 127.0.0.1 | set $server "127.0.0.1"; | connect() failed (111: Connection refused) ... upstream: "http://127.0.0.1:8000/" | 502 |
Forward Hostname app, Forward Port = host port (18080) | set $port 18080; | connect() failed (111: Connection refused) ... upstream: "http://10.201.13.3:18080/" | 502 |
| Forward Hostname a service on another Docker network | set $server "lonely"; | lonely could not be resolved (3: Host not found) | 502 |
Forward Hostname host.docker.internal (with or without extra_hosts) | set $server "host.docker.internal"; | host.docker.internal could not be resolved (3: Host not found) | 502 |
| Forward Hostname the host's gateway address, port the firewall drops | set $server "10.201.13.1"; | upstream timed out (110: Connection timed out) while connecting to upstream | 504 after 90s |
Scheme https, backend plain HTTP | set $forward_scheme https; | SSL_do_handshake() failed (SSL: error:0A00010B:SSL routines::wrong version number) while SSL handshaking to upstream | 502 |
Scheme http, backend HTTPS only (nginx) | set $forward_scheme http; | nothing; the backend answers | 400, "The plain HTTP request was sent to HTTPS port" |
| Websockets Support off, WebSocket backend | no Upgrade lines | nothing | 426 from the backend |
| Forward Hostname a container IP, container recreated | set $server "10.201.13.3"; | connect() failed (111: Connection refused) to the old address | 502, permanently |
| Block Common Exploits on, query matches a rule | include conf.d/include/block-exploits.conf; | nothing | 403, not 502 |
The lines with "could not be resolved" never touch the network: nginx failed at the DNS step. The lines with 111 reached a machine where nothing listens on that port. The 110 line reached nothing at all. Those three groups tell you where to look before you change anything.
How the cases were reproduced#
Everything here was observed on 25 October 2026 unless attributed to documentation or source. The proxy was the jc21/nginx-proxy-manager:2.16.0 image (the release of 24 September 2026), whose nginx -v reports openresty/1.31.1.1, on Docker Engine 28.1.1 on a Linux host. NPM ran on a user-defined Docker network with no published ports; requests were sent with curl to NPM's container address with a Host header per proxy host. Proxy hosts were created through NPM's own API, so the generated files are the ones the UI would write. Backends were Python http.server containers, an nginx 1.30.6 container serving only HTTPS on 8443, and a Python websockets 17.3 echo server on 8765. The host's firewall drops new inbound TCP connections except on ports 49152 to 65535, which is what produced the 504 row.
Read the right log first#
NPM's generated file sets a separate error log per proxy host:
access_log /data/logs/proxy-host-1_access.log proxy;
error_log /data/logs/proxy-host-1_error.log warn;The number is the proxy host's ID, the same number as the generated file under /data/nginx/proxy_host/. To find the right ID, grep the generated files for the domain, then read that host's last error line:
docker exec npm grep -l 'server_name app.example.com' /data/nginx/proxy_host/*.conf
docker exec npm tail -5 /data/logs/proxy-host-1_error.logIf you mounted /data from the host, the same files are under that directory. The access log format NPM defines (log-proxy.conf) includes the upstream status and the hostname it sent to, which shows whether the upstream ever answered:
[25/Oct/2026:03:30:21 +0000] - 502 502 - GET http loop.test "/" ... [Sent-to 127.0.0.1] ...
[25/Oct/2026:03:30:21 +0000] - - 502 - GET http localhost.test "/" ... [Sent-to localhost] ...A - in the second field means no upstream was contacted, which is the DNS failure case. Requests that match no proxy host go to the fallback logs, /data/logs/fallback_http_access.log and /data/logs/fallback_error.log. The 502 page itself says only 502 Bad Gateway with openresty underneath, so the browser tells you nothing; the log is the diagnosis.
localhost and 127.0.0.1: two different failures, same wrong machine#
NPM runs in a container, and the container has its own loopback interface. 127.0.0.1 in the Forward Hostname field is NPM's container, not the host and not the app's container. nginx connected there and nothing was listening: connect() failed (111: Connection refused) while connecting to upstream, ... upstream: "http://127.0.0.1:8000/".
localhost fails before that. Because NPM's proxy_pass uses a variable, nginx does not look the name up in /etc/hosts the way plain nginx with a literal proxy_pass http://localhost:8000; would. It sends the query to the generated resolver, 127.0.0.11, Docker's embedded DNS, which has no record for localhost. The log said localhost could not be resolved (3: Host not found), and the access log showed no upstream at all. So if you find that line, nothing will fix it except a different Forward Hostname.
What to type instead depends on where the app runs, and it is the same choice as for any nginx in a container pointing at localhost, with one NPM-specific difference about the host case, below. If the app is a container, use its service name and put both on one network. If the app runs on the Docker host itself, use an address, not a name.
Forward Port: the container's port, not the published one#
When NPM and the app share a Docker network, NPM talks to the app's container address directly, so the port is the one the app listens on inside its container. The published port (-p 18080:8000, or ports: ["18080:8000"] in Compose) only exists on the host's interfaces. With Forward Hostname app and Forward Port 18080, nginx resolved app correctly and was refused on the container address: connect() failed (111: Connection refused) ... upstream: "http://10.201.13.3:18080/". With 8000 it answered 200.
NPM's documentation makes the same point from the other side: on a shared network "you don't need to publish ports for your upstream services to all of the Docker host's interfaces". Its Portainer example uses portainer as the hostname and 9000 as the port, a port that "isn't listed in the docker-compose file". Drop the ports: mapping on the app once NPM reaches it by name, and the app stops being reachable from outside around NPM.
The reverse mistake is the host address with the container's port. Pointed at the host's gateway address on port 8000, where nothing on the host listens and the firewall drops the packet, nginx waited and returned a 504 after 90 seconds: NPM's nginx.conf sets proxy_connect_timeout 90s, not nginx's default 60s. A 504 that arrives after exactly a minute and a half, with "while connecting to upstream", is a firewall or a wrong address, never a slow app. Timeout budgets explains why a connect timeout that long hides this kind of fault.
Not on the backend's Docker network: "could not be resolved"#
Docker's embedded DNS answers container names only for containers on the same user-defined network. With the app on a second network, nginx logged lonely could not be resolved (3: Host not found) and returned 502 on every request. Plain nginx would refuse to start with host not found in upstream; NPM starts fine and fails per request, because the name is only looked up when a request arrives. That is why NPM lets you save a proxy host for a name that does not exist.
The fix is the one in NPM's documentation: create a network and attach both stacks to it. In each Compose file:
networks:
default:
external: true
name: proxyafter docker network create proxy. To add NPM to an existing app network instead, list that network under the NPM service, or run docker network connect <network> <npm container>. The documentation warns that "the service name is used as the hostname, so make sure your service names are unique when using the same network": two stacks that both call their app app on one shared network give Docker's DNS two answers for the same name.
Scheme: which mismatch is a 502#
The Scheme drop-down sets $forward_scheme, and it describes the hop from NPM to the app, not the visitor's connection. The SSL tab, with its certificate and Force SSL, is the visitor side. Mixing those up produces the two mismatches, and they behave differently:
| Scheme | Backend speaks | Result | Log |
|---|---|---|---|
https | Plain HTTP | 502 | SSL_do_handshake() failed (SSL: error:0A00010B:SSL routines::wrong version number) while SSL handshaking to upstream |
http | HTTPS only, nginx backend | 400 from the backend, body "The plain HTTP request was sent to HTTPS port" | nothing in NPM's error log; access log shows upstream status 400 |
https | HTTPS, self-signed certificate | 200 | nothing |
The first row is the real 502: NPM opened TLS and the app answered with plain HTTP bytes, which OpenSSL reports as a wrong version number. The second row is the backend's own answer passed through, which is why the page looks like it came from the app and not from NPM. What a backend does with plain HTTP on a TLS port depends on the backend. An earlier test on this site found nginx and Go return 400, while a Node HTTPS server closed the connection and a Python one reset it, both of which a proxy reports as 502; the details are in why nginx logs upstream sent no valid HTTP/1.0 header. The third row matters for appliances and admin panels that only listen on HTTPS with their own certificate: Scheme https works with a self-signed certificate, because NPM's template sets no proxy_ssl_verify and nginx's default is proxy_ssl_verify off. Errors from a backend that does check client certificates are covered in why nginx fails the TLS handshake to an HTTPS upstream.
Websockets Support: a broken socket, not a 502#
With the switch on, the template adds three lines to the server block and to location /:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;With it off, those lines are missing and the Upgrade and Connection headers, which are hop-by-hop, are not passed on. A WebSocket handshake sent through the host with the switch off came back as 426 Upgrade Required from the backend, with the body Failed to open a WebSocket connection: missing Connection header. NPM logged no error. With the switch on, the same handshake got 101 Switching Protocols. So Websockets Support off breaks the live part of an app (a console, a dashboard that stops updating, a "connection lost" banner) while the page itself loads with 200. What the backend answers when the headers are missing is up to the app, often 400 or 426, but a 502 for the whole host points elsewhere. The handshake and the idle timeout that closes quiet sockets are explained in WebSockets through a reverse proxy.
Turning the switch on for every host costs little. Connection $http_connection copies whatever the client sent, so ordinary requests keep their normal Connection value.
Stale container IP: what re-resolves and what does not#
This is where the variable proxy_pass helps NPM, and where one form field quietly removes the help.
Forward Hostname is a service name. The name is resolved per request through 127.0.0.11 with the cached answer kept for valid=10s. The app was removed, another container took its address, and the app came back on a new one. NPM returned 502 with connect() failed (111) to the old address until 9 seconds after the recreate, then 200 again without any reload. That is the behaviour plain nginx needs resolve or a variable for, as explained in nginx 502 after a Docker container restart; NPM has it by default.
Forward Hostname is an IP address. Nothing is resolved, so nothing is refreshed. After the same recreate, the host returned 502 to the old address and kept doing so after nginx reloads. Docker assigns container addresses dynamically, and in this test a container started in between was enough to move the app to a new one, so an IP in that field is a 502 waiting for the next docker compose up. Use the service name.
A Custom Location names the same host and port. The Custom Locations tab uses a different template, _location.conf, and it writes a literal line with no variables:
location /api {
proxy_pass http://app:8000;
}A literal name is resolved once, when nginx loads the configuration, and kept. That alone pins /api to the old address after a recreate. The surprise was its effect on other hosts. With a second proxy host whose Custom Location pointed at app port 8000, the first host's plain location /, which re-resolved within 10 seconds when tested on its own, stayed on the old address for over 60 seconds and recovered only after nginx reloaded. That follows from the nginx rule quoted above: a variable name is "searched among the described server groups" before the resolver, and a literal proxy_pass http://app:8000; anywhere in the configuration creates such a group for app:8000. Every proxy host forwarding to app:8000 then uses the address resolved at load time.
So if a recreated app stays 502 in NPM, search all generated files for a literal line naming it. The default location reaches proxy_pass through an include, so every proxy_pass this finds is a Custom Location or a line pasted into an Advanced tab, and each one is resolved only at load:
docker exec npm grep -n 'proxy_pass' /data/nginx/proxy_host/*.confNPM reloads nginx when a proxy host is created, saved or deleted: new hosts answered within seconds in testing, and deleting the host with the Custom Location cleared the stale address for the other host. docker exec npm nginx -s reload does the same without touching the UI.
Failure modes#
could not be resolved (3: Host not found) for localhost. NPM's resolver has no answer for it. Use a service name or an address.
could not be resolved (3: Host not found) for a container name. NPM is not on that container's network. Attach NPM to it, or put both on one external network.
could not be resolved for host.docker.internal. Expected in NPM even with extra_hosts. Use the bridge gateway address.
connect() failed (111) to 127.0.0.1. That is the NPM container. Change Forward Hostname.
connect() failed (111) to a container address with the published port. Change Forward Port to the port inside the container.
connect() failed (111) to the gateway address. The host app or the published port listens only on 127.0.0.1. Bind to 0.0.0.0 or publish on all interfaces.
connect() failed (111) to an address the container no longer has. A Forward Hostname IP, or a Custom Location pinning the name. Use the service name; reload.
upstream timed out (110) while connecting to upstream, 504 after 90 seconds. A firewall drops the connection, or the address is wrong. Check the port and the host firewall from the Docker bridge interfaces.
wrong version number while SSL handshaking. Scheme https, app speaks HTTP. Set Scheme to http.
No error line, 400 "plain HTTP request was sent to HTTPS port". Scheme http, app speaks HTTPS. Set Scheme to https.
No error line, the page loads but live updates fail. Websockets Support off.
For a status other than 502, or a 502 that matches none of these, read it by cause in 502 vs 503 vs 504, then bisect the path as in the proxy debugging playbook. From inside the NPM container, docker exec npm curl -sv http://app:8000/ tests the same hop nginx uses, without nginx in the way; for a name, remember it uses /etc/hosts and so can succeed where nginx's resolver fails.
Where this answer stops applying#
The results are for NPM 2.16.0 with its bundled OpenResty 1.31.1.1, on Docker Engine 28.1.1 on Linux, with NPM on a user-defined bridge network. The generated configuration comes from the develop branch templates, which matched the files the 2.16.0 image wrote. On the default bridge network NPM gets the host's DNS servers in its resolver line instead of 127.0.0.11, and container names do not resolve at all. Docker Desktop, Podman, network_mode: host for NPM and IPv6 bridge networks were not tested. The firewall row depends on this host's rules. Streams (TCP and UDP forwarding) and redirection hosts use other templates and were not part of the test. Older NPM releases may generate different files; cat /data/nginx/proxy_host/<id>.conf shows what yours runs.
Frequently asked questions#
Where are the Nginx Proxy Manager logs?#
Inside the container under /data/logs/. Each proxy host has proxy-host-<id>_access.log and proxy-host-<id>_error.log, where <id> matches /data/nginx/proxy_host/<id>.conf. Requests matching no host go to the fallback_* logs in the same directory.
Can I use localhost as the Forward Hostname in Nginx Proxy Manager?#
No. NPM runs in a container, so localhost is NPM itself, and because the name goes through Docker's DNS it does not even resolve: the log says localhost could not be resolved (3: Host not found). Use the app's service name on a shared network, or the host's bridge address for an app on the host.
Which port do I put in Nginx Proxy Manager, the host port or the container port?#
The container port, when NPM reaches the app by service name on a shared Docker network. The published host port only exists on the host's interfaces, and using it with a container name gives connect() failed (111: Connection refused). Use the host port only with the host's address.
Does Websockets Support cause 502 Bad Gateway in Nginx Proxy Manager?#
Not in testing. With it off, a WebSocket handshake reached the backend without its Upgrade headers and the backend answered 426. Pages still loaded with 200. Turn it on for apps with live consoles or dashboards, but look elsewhere for a 502 on the whole host.
Should the Scheme be http or https in Nginx Proxy Manager?#
Whatever the app itself listens with, independent of the certificate on the SSL tab. Scheme https to a plain HTTP app is a 502 with wrong version number; Scheme http to an HTTPS-only app usually returns the app's own 400.
Why does Nginx Proxy Manager give 502 after I recreate a container?#
If Forward Hostname is an IP address, the container has a new one; use the service name. With a service name, NPM re-resolves within 10 seconds, unless a Custom Location somewhere names the same host and port, which pins the old address until nginx is reloaded.
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.
- Nginx Proxy Manager documentation, Advanced Configuration (Docker network, custom snippets, DISABLE_RESOLVER)
- Nginx Proxy Manager source, backend/templates/proxy_host.conf
- Nginx Proxy Manager source, backend/templates/_location.conf (Custom Locations)
- Nginx Proxy Manager source, docker/rootfs/etc/nginx/conf.d/include/proxy.conf
- Nginx Proxy Manager source, docker/rootfs/etc/nginx/nginx.conf (timeouts, log paths)
- Nginx Proxy Manager source, prepare/40-dynamic.sh (generated resolver line)
- Nginx Proxy Manager source, backend/templates/_exploits.conf and _assets.conf
- nginx ngx_http_proxy_module, proxy_pass with variables
- nginx ngx_http_core_module, resolver
- Docker Engine networking overview (embedded DNS, custom hosts)
- Docker CLI reference, docker container run (--add-host, host-gateway)
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 connect() failed to [::1] with localhost: the fix
- nginx (13: Permission denied) to upstream: SELinux fix
- 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