Linux & operations
Nginx 502 Bad Gateway: Fixing an Upstream Port Mismatch
Nginx returns 502 when the port in proxy_pass does not match where the app listens. Trace the host, Docker port mapping and bind address with real commands.
An nginx 502 caused by an upstream port mismatch means nginx forwarded the request to a port where nothing is accepting connections, so the connection was refused or reset and nginx answered Bad Gateway. Compare the port in proxy_pass with the port the application actually listens on, and in Docker, with the container port rather than the published host port.
Quick answer
- Read the nginx error log first.
connect() failed (111: Connection refused) while connecting to upstreammeans the address or port inproxy_passis wrong, or the app is down. - Check what is really listening with
ss -ltnpon the host, or inside the container. - With Docker,
-p HOST:CONTAINERmaps the left port on the host to the right port inside the container. Nginx on the host uses the host port. Nginx in another container on the same network uses the container port and the service name. - A service bound to
127.0.0.1inside a container is unreachable from other containers. Bind to0.0.0.0. - Reload with
nginx -t && nginx -s reload, then retest withcurl -v.
What a 502 means here
A 502 is generated by nginx itself. It says nginx reached for the upstream and did not get a usable HTTP response. A port mismatch is one cause among several, so confirm it from the log before changing configuration.
Common error log lines and what they usually mean:
| Log line | Likely cause |
|---|---|
connect() failed (111: Connection refused) while connecting to upstream | Nothing listens at that address and port |
upstream timed out (110: Connection timed out) while connecting to upstream | Wrong IP, firewall drop, or wrong network |
recv() failed (104: Connection reset by peer) | Something listens, but not an HTTP server (wrong protocol or wrong service) |
upstream prematurely closed connection | App crashed or closed the socket mid-request |
no live upstreams while connecting to upstream | Every server in an upstream group is marked unavailable |
The first two rows are the port mismatch family. Refused means the host answered and no process owned the port. Timeout means packets never got an answer.
Find the log with nginx -T | grep error_log. The default is often /var/log/nginx/error.log. In the official Docker image, the logs go to the container's stdout and stderr, so use docker logs <container>.
Step 1: read what nginx is actually using
Do not trust the file you edited. Dump the effective configuration, including includes:
# Assumes nginx is installed on a Linux host and you can run it with sudo
sudo nginx -T 2>/dev/null | grep -nE 'proxy_pass|fastcgi_pass|upstream|server [0-9a-z.:]+;'
Note each target. A single stale proxy_pass http://127.0.0.1:3000; in an included file overrides what you thought you set. The proxy_pass documentation shows the address is a domain name or IP with an optional port. If you omit the port, nginx uses 80 for http, which is a frequent source of a surprising refusal.
Step 2: check what is listening
Now ask the operating system which process owns which port:
# Linux host, iproute2 installed
sudo ss -ltnp | grep -E ':(3000|8080)\b'
Read the local address column carefully:
127.0.0.1:3000accepts connections from the same machine only.0.0.0.0:3000or*:3000accepts on all IPv4 interfaces.[::1]:3000is IPv6 loopback only. If nginx useslocalhost, it may resolve to a different address family than the one your app bound.
Then test the upstream directly, bypassing nginx:
curl -v http://127.0.0.1:3000/
If this fails with "connection refused", nginx is not the problem. Fix the app, or fix the port in proxy_pass. If it succeeds, the port is right and you should look at host names, networks, or protocol.
Step 3: untangle Docker port mapping
Docker adds two port numbers to one service, and mixing them up is the classic mismatch. According to the Docker port publishing docs, -p 8080:80 maps port 8080 on the host to port 80 in the container. Left is host, right is container.
Which number you use depends on where nginx runs:
| Nginx runs | App runs | Use in proxy_pass |
|---|---|---|
| On the host | In a container with -p 8080:80 | http://127.0.0.1:8080 |
| In a container on the same user-defined network | In a container | http://app:80 (service name, container port) |
| In a container, app on the host | On the host | The host's address, not localhost (see below) |
The Compose networking guide states that service-to-service communication uses the container port, and that the host port only matters for access from outside the Compose network. It also says services are reachable by name through Compose's internal DNS.
Inspect the real mapping instead of reading the YAML:
# Docker Engine 28 or newer, Compose v2 plugin
docker compose ps
docker port <container-name>
docker inspect -f '{{json .NetworkSettings.Ports}}' <container-name>
An empty result from docker port means nothing is published. That is fine for container-to-container traffic, but fatal if nginx runs on the host.
A Compose example that works
This is illustrative configuration, not taken from a production system. It assumes Compose v2 and an app image that listens on port 3000:
services:
app:
image: example/app:latest # placeholder image
expose:
- "3000" # documentation only, does not publish
environment:
HOST: 0.0.0.0 # bind all interfaces inside the container
PORT: "3000"
proxy:
image: nginx:stable
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
# nginx.conf mounted as conf.d/default.conf in the nginx container
server {
listen 80;
location / {
proxy_pass http://app:3000; # service name + CONTAINER port
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
If you wrote proxy_pass http://app:8080; because you had once published 8080:3000, nginx would get a refusal. The published host port does not exist on the Compose network.
The localhost trap
Inside the nginx container, localhost is the nginx container. proxy_pass http://localhost:3000; sends traffic to nginx itself, finds nothing on 3000, and returns 502. Use the service name.
For an app running directly on the host, Docker Engine on Linux does not provide host.docker.internal by default. You can add it with extra_hosts: ["host.docker.internal:host-gateway"] in Compose. Docker Desktop on macOS and Windows provides that name already, so the same config behaves differently across platforms. The app must also listen on an address the container can reach, not only on 127.0.0.1.
Step 4: check the bind address inside the container
Many frameworks default to listening on loopback in development. Inside a container, loopback is private to that container. Another container connecting to the container's IP gets a refusal even though the port number is correct.
# Run from the host; assumes the image contains ss (iproute2) or use netstat
docker exec <app-container> ss -ltn
You want 0.0.0.0:3000, not 127.0.0.1:3000. Configure it in the app, usually through a HOST variable or a --host 0.0.0.0 flag. How you set it depends on the framework, so check its documentation.
When you publish a port for a host-side nginx, you can still keep it private. Docker documents that adding the loopback address, as in -p 127.0.0.1:8080:80, means only the Docker host can reach that published port. Without an IP the mapping binds to all host addresses, which may expose the app directly. The same page notes a caveat for releases older than 28.0.0, where hosts on the same L2 segment could reach ports published to localhost, so check your Docker version.
Step 5: rule out the other causes
If ports and bind addresses match and you still see 502, check these:
Startup order and health
Nginx may start before the app is ready. A short outage during deploys produces 502s until the app listens. Add a health check and use
depends_onwithcondition: service_healthyso nginx waits.DNS caching
When
proxy_passuses a hostname in plain configuration, nginx resolves it at startup or reload. If a container is recreated with a new IP, nginx keeps the old one until reload. Reload nginx after redeploys, or use a variable with aresolverdirective. The proxy docs note that with variables, a name is looked up among server groups first and then through the resolver.Upstream group failure marking
In an
upstreamblock, the upstream module marks a server unavailable aftermax_failsfailures (default 1) withinfail_timeout(default 10s). With several servers, one wrong port can cause intermittent 502s as requests rotate. With a single server in the group, those parameters are ignored and the server is never marked unavailable.Wrong protocol
If the app speaks HTTPS or gRPC on that port,
proxy_pass http://...fails with resets or invalid headers. Match the scheme, or use the right module such asgrpc_pass.
Verify the fix
Run the checks in order so you know which layer you repaired:
sudo nginx -t && sudo nginx -s reload # on a host install
docker exec <proxy-container> nginx -t && docker exec <proxy-container> nginx -s reload
# From inside the proxy container, test the exact upstream nginx will use
docker exec <proxy-container> sh -c 'curl -sv http://app:3000/ -o /dev/null'
The image may not include curl. If so, run a throwaway container on the same network, for example docker run --rm --network <project>_default curlimages/curl -sv http://app:3000/. The network name is shown by docker network ls.
To make future incidents quicker, log the upstream address and status per request:
log_format upstream_debug '$remote_addr "$request" $status '
'upstream=$upstream_addr ustatus=$upstream_status';
access_log /var/log/nginx/access.log upstream_debug;
$upstream_addr shows the exact address and port nginx tried, which makes a mismatch visible in one line.
Where this fits in larger systems
Port mismatches are small, but they come from a larger issue: several layers each own a port number, and nothing checks that they agree. Application config, the container image, the Compose file, the reverse proxy and the firewall all hold a copy. Keeping one source of truth, for example a single environment variable that feeds both the app and the proxy template, removes most of them.
Alexandru Talnaci works as co-founder and Technical Lead at Legacies in Bucharest, where he leads backend architecture, integrations and product stability. Stable deployment topology is part of that work, and you can see current Legacies products at legacies.ro. If you need help with a full-stack or systems project, look at the selected work or get in touch.
Frequently Asked Questions
Why does nginx return 502 instead of 504 for a wrong port?
A 502 means nginx got no valid response, and a refused connection fails immediately. A 504 means nginx waited for the upstream and the time limit ran out. A wrong port on a live host usually gives a refusal and 502, while a wrong IP or a firewall drop often ends in a timeout and 504.
Should I use the host port or the container port in proxy_pass?
It depends on where nginx runs. Nginx on the host uses the published host port, the left side of -p HOST:CONTAINER. Nginx in a container on the same Docker network uses the service name and the container port, the right side.
Why does curl work from the host but nginx in Docker still fails?
The host reaches the app through the published port on localhost, but inside the nginx container localhost refers to the container itself. Use the service name and container port in proxy_pass. Also confirm the app binds to 0.0.0.0 rather than 127.0.0.1.
Does EXPOSE in a Dockerfile fix a port mismatch?
No. EXPOSE and the Compose expose key document which port the app uses, but they do not publish it to the host. Container-to-container traffic on a shared network does not need publishing at all. Only the real listening port of the app matters.