Docket

TROUBLESHOOTING

502 Bad Gateway between your proxy and your container

Connection refused while connecting to upstream nearly always means the proxy is dialling its own loopback rather than the app.

7 MIN READ Last updated 23 August 2026

Your proxy returns 502 Bad Gateway and its error log says connect() failed (111: Connection refused) while connecting to upstream. The app container is running, and curl on the host reaches it perfectly. The proxy is almost certainly pointed at localhost, and inside its own container that means itself. Point it at the app's service name instead.

The Docker behaviour below was checked against Docker's own networking documentation on 23 August 2026.

Why does localhost mean something different inside a container?

Every container gets its own network namespace. That is not a detail, it is the point: 127.0.0.1 inside the proxy container is the proxy container, so proxy_pass http://localhost:3000 asks the proxy to connect to a port on itself, finds nothing listening, and returns 502. The same configuration works untouched when nginx runs directly on the host, which is why the line was probably copied from a guide where it did.

The address that does work is the container's name on a shared network. Docker's own networking documentation draws the line clearly: containers on the default bridge network "cannot refer to each other by name", while containers on a user-defined network "can communicate with each other using container IP addresses or container names", resolved by an embedded DNS server at 127.0.0.11. Docker Compose creates a user-defined network for the project automatically, so in a compose stack the service name is already a working hostname.

The second half of the same mistake is the port. A line like ports: "8080:3000" publishes port 8080 on the host and does nothing to the container's own port, which is still 3000. From inside the network you use the internal one. From the host you use the published one. Getting this backwards produces the identical 502.

This combination is common enough to have generated two of the highest-traffic questions in the whole area, nginx docker container: 502 bad gateway response at 244,984 views and Docker nginx reverse proxy gives 502 Bad Gateway at 158,160 views, plus a thread on Traefik's own community forum running to 13 posts and 32,008 views.

How do you find out which half is broken?

Four commands, in order, and stop as soon as one of them tells you something.

1. Confirm the app container is actually up rather than restarting.

docker compose ps
docker compose logs --tail=30 app

2. Ask the proxy container to reach the app, from inside the network. This is the test that matters, because it uses the same name resolution and the same route the proxy uses:

docker compose exec proxy curl -sS -o /dev/null \
  -w "%{http_code}\n" http://app:3000/

3. If the proxy image has no curl in it, borrow one on the same network.

docker run --rm --network board_default curlimages/curl \
  -sS -o /dev/null -w "%{http_code}\n" http://app:3000/

4. Confirm both containers really are on that network.

docker network inspect board_default \
  --format '{{range .Containers}}{{.Name}} {{end}}'

If step 2 or 3 prints a status code, the app is fine and the proxy configuration is wrong. If it prints a connection error, the proxy is fine and the app is not listening where you think.

What does the corrected configuration look like?

For nginx, the service name and the internal port, with the forwarded headers set at the same time so you do not come back for them later:

location / {
  proxy_pass http://app:3000;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
}

Reload rather than restart, so a syntax error does not take the site down with it:

docker compose exec proxy nginx -t
docker compose exec proxy nginx -s reload

For Caddy the whole thing is one line, and it sets the forwarded headers itself:

board.example.com {
  reverse_proxy app:3000
}

For Traefik there is no upstream address to write at all, only labels, which is why its failure mode looks different. The port in the label is the container's internal port, and Traefik has to be attached to the same network as the app:

labels:
  - "traefik.enable=true"
  - "traefik.http.services.board.loadbalancer.server.port=3000"

Where the proxy runs decides which address is correct, and it is worth being explicit about all four cases:

Where the proxy runsAddress it should use
A container on the same compose networkThe service name and the container's internal port, http://app:3000
A container on a different networkAttach it to the app's network first, then use the service name
Directly on the hostThe host-published port, http://127.0.0.1:8080, and the app must use ports: rather than expose:
Behind a second proxy or a tunnelThe address the nearest hop can reach, checked from inside that hop, never assumed

How do you tell it worked?

Ask from outside, the way a visitor would, and read the status line rather than the page:

curl -sS -o /dev/null -w "%{http_code}\n" https://board.example.com/

Then watch the app's own log while you reload the page in a browser. A request appearing in the app log is the proof that the two halves are talking; a 200 with an empty access log means something in front is answering from cache. Finally, click through a post, a vote and a comment, because a proxy that passes the homepage and mangles the API is a different bug wearing the same clothes.

Your own support centre, in your own repository

One payment, no subscription, unlimited products.

Same 502, different cause

What you seeWhat it usually is
Everything above is correct and it still refusesThe app is bound to 127.0.0.1 inside its own container. It has to listen on 0.0.0.0 to be reachable from anywhere else
502 for the first few seconds after a deploy, then fineThe proxy is up before the app has finished booting. Add a healthcheck and make the proxy depend on it
nginx will not start at all, host not found in upstreamNot a 502. nginx resolves upstream names when it loads its configuration, so a name that does not exist yet is fatal. Use a variable with resolver 127.0.0.11 valid=10s;
504 rather than 502The connection succeeded and the response did not arrive in time. That is a slow app or a low timeout, not a wiring problem
502 only on file uploads or long requestsBuffering and timeout limits at the proxy, not addressing
Traefik says Bad Gateway with correct-looking labelsTraefik is not attached to the app's network, or the port label is the published port rather than the internal one
The container will not start, port is already allocatedAn earlier failure. Nothing is listening, so the 502 is a symptom rather than the fault

The Traefik case has a diagnostic worth stealing whatever proxy you run. Someone in that community thread suggests finding the service in the dashboard, noting the URL Traefik thinks it should call, then executing into the Traefik container and curling exactly that URL. It converts a vague routing question into a yes or no answer in about thirty seconds.

Does a static support centre have an upstream to lose?

Docket has no container and no upstream of its own, because there is nothing to proxy to: it is static files plus two small endpoints, served by whichever host you deploy it to. That removes this specific failure rather than making it irrelevant, and if you choose to put your own nginx in front of it, every rule on this page applies again exactly as written. The trade between the two shapes, and what each one actually costs to run, is in static site or server.

Frequently asked questions

Why does the app work with curl on the host but 502 through the proxy?

Because those two requests take different routes. curl on the host uses the published port through Docker's port mapping. The proxy, if it is itself a container, uses the internal network and needs the service name and the container's internal port. Both can be true at once: the app is fine, and the proxy is calling an address that does not exist from where it stands.

Should I use the container name or the compose service name?

The service name, as written in docker-compose.yml. Compose registers it on the project network as an alias, so it survives the container being recreated with a different generated name. Hard-coding a container name works until the next docker compose up renames it.

Is 502 ever the app's fault rather than the proxy's?

Yes. A 502 means the proxy could not get a valid response, which includes the app accepting the connection and then dying mid-request, or crashing on start and leaving nothing listening. Read the app's log first, then the proxy's. If the app log is empty during a failing request, the request never reached it.

Does host.docker.internal fix this?

It solves the opposite problem. host.docker.internal lets a container reach a service on the host machine, which is useful when the app runs outside Docker and the proxy runs inside. It is not the way to reach another container, and on Linux it is not available by default.

Why does the same nginx config work on my laptop and not on the server?

Usually because one of them runs nginx on the host and the other runs it in a container, so localhost means two different things. Occasionally because Docker Desktop and Linux differ on host.docker.internal. Check where nginx is actually running before you change a line of the configuration.