Ordered by how often each one turns out to be the answer.
Nothing is listening at the address the proxy is using
CommonThe upstream process is not running, is still starting, or is listening somewhere other than where the proxy is looking. The proxy's connection is actively refused, which is a fast failure rather than a hang — the 502 arrives immediately.
Confirm
sudo tail -50 /var/log/nginx/error.log | grep -i 'connect() failed'
`(111: Connection refused)`. That is the kernel saying nothing holds the port, which is unambiguous — the address in the message is where the proxy tried.
Fix
- Confirm the upstream is running and listening on exactly the address in the proxy config, with `ss -tlnp | grep <port>`.
- Check the bind address, not just the port. A process on `127.0.0.1:3000` is unreachable from a proxy in a different container or on another host, even though the port is correct.
- If the upstream is a container, check the proxy is using the container name and container port rather than a published host port.
ss -tlnp | grep ':3000' # is anything listening, and on what address?
curl -sv http://127.0.0.1:3000/ -o /dev/null # from the proxy's own host
Run the curl from wherever the proxy runs. Reachability from your laptop proves nothing about reachability from the proxy.
The upstream accepted the connection and then died
CommonThe connection succeeded, so this is not a reachability problem. The application then crashed, was killed, or closed the connection before sending a complete response — most often an unhandled exception in a worker, or the process being OOM-killed while handling the request.
Confirm
sudo tail -50 /var/log/nginx/error.log | grep -i 'prematurely closed\|upstream sent'
`upstream prematurely closed connection while reading response header`. This is the signature — the proxy got a socket and then got nothing.
Fix
- Look at the upstream's own logs at the timestamp of the 502. A crash usually leaves a stack trace, and this cause is the one where the application has the most to say.
- Check whether the process was killed rather than crashed. Exit code 137 or an OOM entry means the request pushed it over a memory limit.
- If it is intermittent and correlates with load, suspect worker exhaustion or a per-request memory spike rather than a logic bug.
The proxy is configured for the wrong address
CommonA hostname that no longer resolves, a service name that does not exist on the proxy's network, or a stale address after a deploy. Common the first time a proxy and an application are containerised together, because `localhost` in the proxy config means the proxy's container.
Confirm
sudo nginx -T 2>/dev/null | grep -A3 'proxy_pass\|upstream ' | head -30
The actual resolved configuration, not the file you think is loaded. `nginx -T` prints what is in effect, which is frequently not what you last edited.
Fix
- Resolve the upstream name from inside the proxy: `docker exec proxy getent hosts api`. A name that does not resolve is a different fault from a connection refused.
- In compose, the upstream is the service name and the container port. Neither `localhost` nor the published host port is correct from inside the proxy container.
The upstream is speaking a different protocol than the proxy expects
OccasionalThe connection works and bytes come back, but they are not what the proxy is parsing. Usually `proxy_pass http://` against a TLS listener, or an HTTP/1.1 proxy in front of a gRPC or HTTP/2-only service.
Confirm
curl -sv http://<upstream-host>:<port>/ -o /dev/null 2>&1 | head -20
A TLS handshake error, or a response that is not recognisable HTTP. If plain HTTP fails but `https://` works, the scheme in `proxy_pass` is the fault.
Fix
- Match the scheme to the listener: `proxy_pass https://` for a TLS upstream, and `grpc_pass` for gRPC.
- For an HTTP/2-only upstream, either enable the appropriate module or put an HTTP/1.1-capable listener in front of it.
Every upstream in the pool has been marked unavailable
OccasionalWith multiple upstreams, the proxy takes failing ones out of rotation. Once all of them are out, every request 502s — including requests that would now succeed, because a passively-marked upstream is not retried until its window expires.
Confirm
sudo tail -50 /var/log/nginx/error.log | grep -i 'no live upstreams'
`no live upstreams while connecting to upstream`. That is the whole pool, not one member.
Fix
- Fix the underlying upstream failure — the pool being empty is a consequence rather than the cause.
- Review `max_fails` and `fail_timeout`. Aggressive values will eject the entire pool during a brief wobble and then refuse to try again for the timeout period.