DevOps

Fix Nginx 502 in Docker Compose: upstream localhost

Trace the upstream address in the Nginx log, prove the Node listener is reachable from the proxy, and separate a wrong host from a loopback-only bind.

Mahdi Ben RhoumaMahdi Ben Rhouma
7 min read

TL;DR#

A Nginx 502 with an upstream address of http://127.0.0.1:3000/ means the proxy is trying to reach port 3000 in its own container. In a normal Compose bridge network, change the upstream to web:3000, where web is the Node service name. Keep the container port, ensure Node accepts connections beyond its own loopback, then test and reload the Nginx configuration. Startup ordering cannot repair the wrong address.

The Stack Overflow report describes a Node app that works directly on localhost:3000, while Nginx on localhost:8080 returns a 502. Its error identifies the destination:

text
connect() failed (111: Connection refused) while connecting to
upstream, client: 172.18.0.1, server: localhost, request: "GET /
HTTP/1.1", upstream: "http://127.0.0.1:3000/", host: "localhost:8080"

Seeing the Nginx error page proves that the browser reached the proxy. It does not prove that the proxy reached Node. Those are two separate connections, and the second one is the connection refused here.

Why it happens#

When both processes run directly on your laptop, they share its network stack: Nginx can reach Node through the laptop's loopback. Moving them into separate Compose services changes the meaning of the same address. Docker's networking documentation explains container network namespaces and explicitly identifies 127.0.0.1 as the container's own loopback address.

For this bridge-network setup, the accepted answer's description of that address as the host machine's loopback is incorrect. Its practical change to the service name is the useful part. Nginx is neither dialling the laptop nor discovering the other container when its upstream says loopback; it is dialling itself.

Compose networking supplies the route: services on the default project network can discover each other by service name. The proxy's destination is the Node service name plus the port that Node actually listens on inside that container. A published host port serves clients outside that network.

The one-change test#

Reproduced on 13 October 2026 with Node 22.23.3 in node:22-alpine, Nginx 1.30.5 in nginx:stable-alpine, Docker Engine 29.7.2 and Compose 5.5.0. Both services used one default Compose network. Neither service published a host port. Nginx already had a short-form dependency on web, and the Node listener was confirmed ready before the failing request.

From inside Nginx, requesting Node directly through its service name returned the application's response. Requesting Nginx's own HTTP endpoint returned a 502 because its upstream was still loopback. Changing only the upstream server address from loopback to web:3000, then reloading that configuration, produced the same Node response through the proxy:

text
  HTTP/1.1 200 OK
Node upstream OK

No image rebuild, additional network, legacy link, port publication or startup dependency was added between failure and success. That isolates the wrong destination as the cause in this case.

Fix#

  1. Use the service name in the upstream configuration. If your service is named api rather than web, use api in this file. Save this complete server configuration as nginx/default.conf; the official Nginx image loads that directory within its existing HTTP configuration:

    nginx
    upstream node_app {
      server web:3000;
    }
     
    server {
      listen 8080;
      server_name localhost;
     
      location / {
        proxy_pass http://node_app;
      }
    }

    Keep the upstream group name consistent with the proxy target. The Nginx proxy module documents proxy_pass as the destination for forwarded requests. Editing an unused configuration file will not change that destination in the running process.

  2. Keep both services on the same Compose network. This minimal Compose file uses your existing Node Dockerfile and mounts the proxy configuration. It publishes only the proxy endpoint, with a host port selected by Docker to avoid local collisions:

    yaml
    services:
      web:
        build: .
      nginx:
        image: nginx:stable-alpine
        ports:
          - "8080"
        volumes:
          - ./nginx:/etc/nginx/conf.d:ro
        depends_on:
          - web

    The absence of a ports entry on web is intentional: Nginx can reach its listener over the shared network. Publishing the app separately is optional for direct host access. If your database container instead fails to start with a host-port conflict, follow the Docker port already in use diagnosis; changing the upstream is not a remedy for that different failure.

  3. Apply the changed configuration. From the directory containing the Compose file, start the services, test the Nginx configuration and reload it:

    bash
    docker compose up -d
    docker compose exec nginx nginx -t
    docker compose exec nginx nginx -s reload

    Proceed with the reload after the configuration test succeeds. The Nginx control documentation describes how a reload applies the new configuration. If the configuration is copied into a custom image instead of mounted, rebuild and recreate that service to deploy the edited file; a reload cannot read a change that never reached the container.

Verify the fix#

Test from the proxy container rather than relying on a browser request to Node's published host port. The official Alpine Nginx image used in the reproduction includes wget:

bash
docker compose exec nginx wget -S -O - http://web:3000
docker compose exec nginx wget -S -O - http://127.0.0.1:8080
docker compose logs nginx web

The first request bypasses the proxy and checks whether this container can reach the Node service. The second exercises Nginx and its configured upstream. Both should return the response expected from your application's root route. For a route protected by authentication, choose a known public endpoint instead of assuming every successful connection must return 200.

If the direct request succeeds but the proxy still reports loopback in its error log, inspect the configuration loaded by Nginx:

bash
docker compose exec nginx nginx -T

Look for the upstream actually referenced by your server block. This helps identify an old image, the wrong mount or a different configuration being edited. The reproduction's configuration dump contained server web:3000 after the successful reload.

To test from your browser afterwards, discover the assigned host port:

bash
docker compose port nginx 8080

Use that returned port on localhost. It is the browser-facing port; it does not replace 3000 in the Node upstream. The existing Docker development setup covers the wider Compose environment, networks and healthchecks when this proxy is part of a larger stack.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

When web resolves but Nginx still gets a 502#

A correct upstream address can expose a second problem: Node listens only on its own loopback. The Node server.listen documentation identifies the host argument as the interface on which the server listens. For a plain Node HTTP server intended to accept IPv4 connections from another container, an explicit all-interface listener looks like this complete server:

javascript
const http = require('node:http');
 
http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Node upstream OK\n');
}).listen(3000, '0.0.0.0');

This is a correction when the existing application explicitly binds to loopback. An application already listening on an appropriate interface needs no additional binding change. Keep the upstream destination as web:3000: the all-interface address is a listener setting, not the name of a remote service.

That second cause was reproduced separately. With the fixed upstream left unchanged, only Node's bind address was changed to loopback, and the same web container was restarted. A request from inside web still returned 200, but Nginx's direct request to web failed:

text
Connecting to web:3000 (172.19.0.2:3000)
wget: can't connect to remote host (172.19.0.2): Connection refused

The proxy then returned a 502 again, this time with the web container's private address in the upstream error. Restoring only Node's all-interface listener and restarting that same container restored the proxy response. A successful self-test inside web therefore cannot establish reachability from Nginx.

Finally, consider readiness when the address and listener are correct but failures occur only during startup. Docker's startup-order documentation distinguishes a running container from a ready service. The short depends_on form in the minimal example establishes ordering; it does not wait for Node to accept requests. For a slower application, add an application healthcheck and require the web dependency to be service_healthy. Test an endpoint that represents the readiness your requests need, and retain the correct upstream address.

If another component in the same stack now reports ECONNREFUSED on port 5672, continue with the RabbitMQ container connection fix. It checks the AMQP listener rather than assuming that a running broker is ready. For this HTTP proxy, keep the same distinction: the address in Nginx's error tells you where it tried to connect, and the direct request from Nginx tells you whether that destination is currently accepting connections.

Further reading: Fix Sequelize "Cannot find /config/config.json" in Docker.

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote