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 upstream means the address or port in proxy_pass is wrong, or the app is down.
  • Check what is really listening with ss -ltnp on the host, or inside the container.
  • With Docker, -p HOST:CONTAINER maps 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.1 inside a container is unreachable from other containers. Bind to 0.0.0.0.
  • Reload with nginx -t && nginx -s reload, then retest with curl -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 lineLikely cause
connect() failed (111: Connection refused) while connecting to upstreamNothing listens at that address and port
upstream timed out (110: Connection timed out) while connecting to upstreamWrong 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 connectionApp crashed or closed the socket mid-request
no live upstreams while connecting to upstreamEvery 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:3000 accepts connections from the same machine only.
  • 0.0.0.0:3000 or *:3000 accepts on all IPv4 interfaces.
  • [::1]:3000 is IPv6 loopback only. If nginx uses localhost, 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 runsApp runsUse in proxy_pass
On the hostIn a container with -p 8080:80http://127.0.0.1:8080
In a container on the same user-defined networkIn a containerhttp://app:80 (service name, container port)
In a container, app on the hostOn the hostThe 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:

  1. 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_on with condition: service_healthy so nginx waits.

  2. DNS caching

    When proxy_pass uses 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 a resolver directive. The proxy docs note that with variables, a name is looked up among server groups first and then through the resolver.

  3. Upstream group failure marking

    In an upstream block, the upstream module marks a server unavailable after max_fails failures (default 1) within fail_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.

  4. 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 as grpc_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.

About the author

Alexandru Talnaci

Full-stack and systems engineer in Bucharest. Co-founder and technical lead at Legacies, working across web applications, integrations and C++ systems.

Explore the projects behind the notes
Back to all notesSubscribe via RSS