Debugging 502 Bad Gateway From a Local Reverse Proxy
The router exists, the certificate is trusted, and the proxy answers — with 502 Bad Gateway. Traefik logs dial tcp 172.20.0.4:3000: connect: connection refused, nginx logs connect() failed (111: Connection refused) while connecting to upstream, and Caddy logs dial tcp: lookup api on 127.0.0.11:53: no such host. A 502 always means the same thing: the proxy accepted the request but could not get a valid response from the container behind it. This page, part of local HTTPS and reverse proxies, narrows that down to one of four causes in a few commands.
The key to fixing 502s quickly is to stop testing from the browser. The browser can only tell you that something between the proxy and the application failed; the proxy container can tell you exactly what.
Diagnostic
Reproduce the proxy's own upstream request from inside the proxy container, then look at what the upstream is actually listening on:
#!/usr/bin/env bash
set -euo pipefail
svc="${1:-api}"; port="${2:-8080}"
docker compose logs proxy --since 5m | grep -iE '502|refused|no such host|timeout' | tail -5 || true
docker compose exec -T proxy wget -qO- --timeout=3 "http://$svc:$port/health" || echo "proxy -> $svc:$port FAILED"
docker compose exec -T "$svc" sh -c 'ss -ltnp 2>/dev/null || netstat -ltnp'
docker compose ps "$svc" --format '{{.Service}} {{.State}} {{.Health}}'
Expected bad output for the most common case:
level=debug msg="'502 Bad Gateway' caused by: dial tcp 172.20.0.4:8080: connect: connection refused"
wget: can't connect to remote host (172.20.0.4): Connection refused
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("node",pid=1,fd=21))
api running healthy
The container is running and healthy by its own measure, but the server is bound to 127.0.0.1:8080 — the container's private loopback — so connections from the proxy on another IP are refused.
Root cause
A reverse proxy in Compose reaches each upstream over the Docker network, using the container's network IP. Anything that prevents a TCP connection to that IP and port produces a 502. The four causes are: the application binds only to loopback (the default for Vite, Next.js dev, Rails, Django runserver and many others, which expect a browser on the same machine); the port label or proxy_pass points at a different port than the server uses; the proxy and upstream are on different Docker networks, so the service name does not resolve; or the upstream process has not finished starting and nothing is listening yet. The container's healthcheck often hides the first cause, because it runs inside the container where loopback works.
Resolution
- Bind the dev server to all interfaces. Each framework has its own flag; set it in the Compose command so it cannot be forgotten:
services:
web:
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
api:
command: ["python", "manage.py", "runserver", "0.0.0.0:8080"]
rails:
command: ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
Binding 0.0.0.0 inside a container is safe: the container's interfaces are only reachable from the Docker network, and nothing is exposed on the host unless you publish a port.
- Make the upstream port explicit. Compare what the label says with what
ssshowed:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json | jq -r '.services | to_entries[] | .key as $s | (.value.labels // {}) | to_entries[] | select(.key | endswith("loadbalancer.server.port")) | "\($s) -> \(.value)"'
If the label says 3000 and the server listens on 8080, fix the label, not the server — the label describes the container, and the container's port is usually defined by the framework.
- Put proxy and upstream on a shared network. When services come from several Compose projects, each project has its own default network. Create one external network and attach both sides, as described in connecting containers across Compose projects:
networks:
edge:
external: true
services:
api:
networks: [default, edge]
labels:
- traefik.docker.network=edge
The traefik.docker.network label tells Traefik which of the container's networks to use when it has more than one; without it, Traefik can pick an IP on a network the proxy is not attached to.
- Gate the route on readiness. Add a healthcheck that tests the same port the proxy uses, from the network side:
services:
api:
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://$$(hostname -i):8080/health || exit 1"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
Traefik skips containers whose health status is starting or unhealthy, so during startup the route returns 404 briefly instead of a misleading 502, and the healthcheck now catches a loopback-only bind because it connects through the container IP.
Expected output
$ docker compose exec -T proxy wget -qO- --timeout=3 http://api:8080/health
{"status":"ok"}
$ docker compose exec -T api ss -ltn | grep 8080
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:*
$ curl -sS -o /dev/null -w '%{http_code}\n' https://api.localhost/health
200
The server listens on all interfaces, the proxy reaches it by service name, and the browser-facing request returns 200 instead of 502.
Prevention
Healthchecks that use the container IP, as shown above, turn a loopback-only bind into an unhealthy container at startup rather than a 502 in the browser twenty minutes later.
A route smoke test in
make doctor. Loop over every Host rule and request/healththrough the proxy; any non-2xx response prints the service name and the proxy's last log line for it. The make bootstrap guide shows where such a target fits.Framework flags in Compose, not in personal shell aliases. Anything that must be true for the stack to work belongs in the checked-in command.
Platform caveats
macOS (Docker Desktop): file-watching dev servers under heavy bind-mount load can take 30+ seconds to start listening. Raise the healthcheck
retriesrather than treating early 502s as configuration errors.
WSL2: a dev server started in WSL outside Docker binds to the WSL VM, not to the Docker network. Point the proxy at it with
host.docker.internaland aextra_hosts: ["host.docker.internal:host-gateway"]entry, as in reaching host services from a container.
Apple Silicon (ARM64): emulated amd64 upstreams start several times slower. If a 502 resolves itself after a minute, check the image architecture before tuning timeouts.
Rollback
All changes are to Compose configuration. Restore the previous file and recreate:
#!/usr/bin/env bash
set -euo pipefail
git restore compose.yaml
docker compose up -d --force-recreate
Frequently Asked Questions
Why does the healthcheck pass while the proxy gets 502?
A healthcheck that calls localhost runs inside the container, where a loopback-bound server is reachable. The proxy connects from another IP on the Docker network. Make the healthcheck connect to the container's own network IP, or to 0.0.0.0-reachable addresses, so it tests what the proxy sees.
Is binding to 0.0.0.0 inside a container a security risk?
Not on its own. Inside a container, 0.0.0.0 means the container's interfaces, which are only reachable from its Docker networks. Exposure to the host or the LAN happens only through published ports, which this setup avoids for proxied services.
What is the difference between a 502 and a 504 from the proxy?
A 502 means the upstream connection failed or returned an invalid response. A 504 means the connection succeeded but the upstream did not answer within the proxy's timeout, which points to a slow request or a hung process rather than wiring.
Why does nginx keep returning 502 after the upstream restarts?
nginx resolves upstream hostnames once at startup and caches the IP. When the container is recreated with a new IP, nginx keeps dialling the old one. Use resolver 127.0.0.11 valid=10s; with a variable in proxy_pass, or reload nginx after recreating upstreams.