Originally published on the Hubfly blog.
TL;DR
- What you get: a 30-second check that tells you if a 502 is a bind-address bug, the one-line fix for 9 common servers, and 3 traps we hit while testing it.
- Who it's for: anyone whose container logs say "listening" while the browser says 502.
- Time: 5 minutes to diagnose, 1 line to fix.
- Tested: on Hubfly on 16 Oct 2026, with a deliberately broken container in project
blog-lab-502-bind.
Your container is running. The logs say the server started. The dashboard says the port is open. And every request through the proxy gets 502 Bad Gateway.
Often, nothing is "down". Your app is listening on 127.0.0.1, and inside a container that address means "this container only". The proxy knocks on the container's network interface, nobody answers, and you get a 502.
Here's how to prove it in 30 seconds, fix it in one line, and avoid the three traps we walked into while testing.
Why 127.0.0.1 breaks inside a container
Every container has its own network namespace. It has its own loopback (lo, 127.0.0.1) and its own interface (eth0, a private IP).
Internet ──HTTPS──► reverse proxy ──► 10.240.82.2:8000 (eth0) ──✗── app on 127.0.0.1:8000
(only lo can reach it)
A server bound to 127.0.0.1:8000 accepts connections that arrive on lo only. The proxy's request arrives on eth0, so the kernel refuses it. The proxy turns "connection refused" into a 502.
Binding to 0.0.0.0 means "every interface in this container", which includes eth0. It does not open anything on the host or the internet by itself: the container is still only reachable through the routes you set up.
Reproduce it: one broken container
We deployed this on Hubfly with the Compose import. The image is stock python:3.12-alpine, so there's no build step.
services:
broken:
image: python:3.12-alpine
command: ["python", "-m", "http.server", "8000", "--bind", "127.0.0.1"]
expose: ["8000"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/"]
interval: 10s
retries: 3
The stack deployed in 33 seconds:
Then we added a public HTTPS domain on port 8000 (Network → Add Domain → Auto Endpoint) and opened it:
Note the page's advice: "This is usually temporary, try again in a few seconds." For a bind bug, retrying forever won't help. It's a configuration error, not a blip.
Step 1: Prove it's the bind address (30 seconds)
Open a shell in the container. On Hubfly that's the container's Console tab. Elsewhere, use docker exec -it <container> sh.
Run these four commands:
netstat -tln # or: ss -ltn
IP=$(ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1); echo $IP
wget -qO- http://127.0.0.1:8000/ | head -c 40; echo
wget -qO- http://$IP:8000/ | head -c 40; echo
This is the real output from our broken container:
/ # netstat -tln
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 127.0.0.1:8000 0.0.0.0:* LISTEN
/ # IP=$(ip -4 -o addr show eth0 | awk '{print $4}' | cut -d/ -f1); echo $IP
10.240.82.2
/ # wget -qO- http://127.0.0.1:8000/ | head -c 40; echo
<!DOCTYPE HTML>
<html lang="en">
<head>
/ # wget -qO- http://$IP:8000/ | head -c 40; echo
wget: can't connect to remote host (10.240.82.2): Connection refused
How to read it:
Local Address in netstat |
Reachable from the proxy? |
|---|---|
127.0.0.1:8000 or ::1:8000
|
No. Loopback only. This is your 502. |
0.0.0.0:8000 or :::8000
|
Yes, on every interface (:: usually accepts IPv4 too). |
A specific IP such as 10.240.82.2:8000
|
Yes, but it breaks if the IP changes on redeploy. |
If loopback answers and the eth0 IP is refused, you've found it. Use $IP from ip, not hostname -i: in our container hostname -i printed 127.0.1.1, which is another loopback address and proves nothing.
Step 2: Fix it in one line
Change the bind address to 0.0.0.0. In our case that was one argument:
command: ["python", "-m", "http.server", "8000", "--bind", "0.0.0.0", "--directory", "/srv"]
On Hubfly we edited Configuration → Process Settings → Command (one argument per line) and saved. The new command took effect, and the same public URL returned 200 within about 20 seconds. netstat now shows:
tcp 0 0 0.0.0.0:8000 0.0.0.0:* LISTEN
Why --directory /srv? See trap 3 below. It's the part we didn't expect.
The one-line fix for 9 common servers
Most of these default to loopback because they're built for your laptop, where loopback is the safe choice. In a container, it's the wrong one.
| Server | Default you get | Container-friendly command |
|---|---|---|
| Flask dev server | 127.0.0.1:5000 |
flask run --host 0.0.0.0 |
Django runserver (dev only) |
127.0.0.1:8000 |
python manage.py runserver 0.0.0.0:8000 |
| Uvicorn (FastAPI) | 127.0.0.1:8000 |
uvicorn app:app --host 0.0.0.0 --port 8000 |
| Gunicorn |
127.0.0.1:8000 (or 0.0.0.0:$PORT if PORT is set) |
gunicorn -b 0.0.0.0:8000 app:app |
Node http / Express |
all interfaces if you omit the host |
app.listen(3000) or app.listen(3000, "0.0.0.0"). Never "localhost". |
| Vite dev / preview | localhost |
vite --host 0.0.0.0 or vite preview --host 0.0.0.0
|
Go net/http
|
whatever you pass |
http.ListenAndServe(":8080", h), not "localhost:8080"
|
| PHP built-in server | whatever you pass |
php -S 0.0.0.0:8000, not localhost:8000
|
| Rails (development) | localhost:3000 |
bin/rails server -b 0.0.0.0 |
Better still, read the host and port from environment variables (HOST=0.0.0.0, PORT=8000) so the same image runs on your laptop and in production.
What can go wrong
1. The dashboard says the port is open, and it's still a 502.
While our broken container was running, Hubfly's Add Domain dialog listed 8000 under "Suggested ports" as "Open now". In our test the scan counted a loopback-only listener as open. "Open" means something listens on 8000; it doesn't mean the proxy can reach it. Trust netstat's Local Address column instead.
2. Your healthcheck can't see this bug.
A healthcheck that runs wget http://127.0.0.1:8000/ inside the container runs on loopback, so a loopback-only server passes it. We ran that exact command in the broken container and got HTML back. (On Hubfly the health badge showed "Unknown" during our test, so we never saw it turn green. But the check itself passes.)
In-container checks can't catch this bug. Check from outside too: open the public URL, or check from another container: set an env var TARGET={{service:broken.host}} on it, then run wget -qO- http://$TARGET:8000/ in its Console (we didn't test this one).
3. The fix can publish your whole filesystem.
Our first fix only changed 127.0.0.1 to 0.0.0.0. The public URL then showed a directory listing of the container's /: bin/, etc/, root/ and more. python -m http.server serves the working directory, which was /. Adding --directory /srv fixed it.
The lesson is bigger than Python: once you bind to 0.0.0.0, whatever that port serves is public through your domain. Look at the page before you share the link.
Two more causes of 502 that look the same from the browser:
-
Wrong port: the domain points at 80, the app listens on 8000. Match the domain's container port to
netstat. -
The app crashed:
netstatshows nothing listening at all. Check the logs for a stack trace, not the bind address.
Takeaway
A 502 from a container that's "running" is usually a bind address, not an outage. Run netstat -tln. If you see 127.0.0.1, change it to 0.0.0.0, then check what that port now serves to the world.
Read next: Gate your app on a Postgres migration job with depends_on: service_completed_successfully. It covers the other classic "it's running but broken" Compose bug. More tested guides are on the Hubfly blog.
Deploy this
Want to see the bug and the fix side by side? Paste the Compose file above into Create → Compose Stack → YAML in the Hubfly dashboard, add a domain on port 8000, and watch the 502. Then replace the command with the fixed one from Step 2, including --directory /srv. Changing only the bind address publishes the container's whole filesystem. Our test ran on the shared tier (0.3 vCPU, 256 MB), which falls inside the free 360 compute hours a month. See pricing and the CLI docs if you'd rather do it from a terminal.





Top comments (0)