On this page
I did this one to myself, and I’m sharing it because it’s probably the single most common trap in self-hosted monitoring. I’d set up a little demo box — a Docker stack with a Homepage dashboard, Grafana, Prometheus, and Uptime Kuma watching over them — purely so I could take clean screenshots for this site. I added the monitors, saw some red, figured things were still starting up, and moved on to other things.
When I came back to the dashboard — a full day later — I found a perfect wall of red. Twenty-four hours of it. 1,440 failed checks per monitor — one every minute, all night, every single one refused. And here’s the part that makes this a story worth telling: every one of those “down” services was up. I could open each of them in my browser just fine.
Nothing was broken. The monitors were simply asking the wrong machine.
The symptom: red that never blinks
Three HTTP monitors — Homepage, Grafana, Prometheus — all showed the same thing: connect ECONNREFUSED, which is the operating system’s blunt way of saying “nothing is listening at that address and port.” Not a timeout, not a slow response. An instant, confident nope, 1,440 times in a row.
Two details in that picture turned out to be the whole diagnosis, though I didn’t see it right away:
- The red never blinked. A service that’s genuinely struggling flaps — up, down, up, slow, down. A bar that is solid red from the first second and never once succeeds is telling you the check itself can’t reach the target. That’s a different disease.
- The ping monitor stayed green the whole time. Same Uptime Kuma, same 24 hours, no problem. So Kuma wasn’t broken — only its HTTP checks were failing, and all of them the same way.
The wrong guesses first
I’ll be honest about the order I checked things in, because the wrong guesses are half the lesson.
First guess: the services were down. Nope — each one opened instantly in my browser. Second guess: a firewall was blocking Kuma. But there was no firewall between these containers, and ECONNREFUSED isn’t what a firewall usually looks like anyway — a dropped packet times out; refused means something answered immediately and said “nothing here.”
Then I finally looked properly at the monitor URLs I’d typed in: http://localhost:3000, http://localhost:9090… and it clicked. The question that solved it wasn’t “is the service up?” It was “who is asking?”
The real cause: localhost is a point of view
My Uptime Kuma runs in a Docker container. A container isn’t just a process — it gets its own private network world, called a network namespace: its own network interfaces, its own routing, its own ports, and crucially its own loopback address (that’s what localhost points to). This isolation is a kernel feature, and it’s exactly what the Linux network namespaces man page says gets separated: network devices, IP stacks, routing tables, port numbers — all of it, per namespace.
Docker’s own networking docs put it from the container’s side: a container only sees a network interface with an IP address, a gateway and DNS — it has no idea what’s outside, and it even gets its own /etc/hosts, with its own entries for its own hostname and localhost.
Think of the host machine as an apartment building and each container as an apartment. localhost means “my own apartment.” When I told Kuma to check http://localhost:3000, it didn’t walk down the hall to the Homepage apartment — it checked its own port 3000, found nobody home, and reported exactly that. 1,440 times. It never once tested the thing I actually cared about.
And this explains the browser paradox too. When I opened http://my-server:3000, the request hit the host’s port 3000, which Docker had published — mapped through to the Homepage container. My browser and my monitor were typing nearly the same URL and asking two completely different machines.
Inside a container, localhost is that container. Your monitor doesn’t check from where you sit — it checks from where it sits, and every URL you give it is resolved from that vantage point.
The fix: call containers by name
The right fix was almost embarrassingly small. Docker Compose already puts every service in a project on a shared private network, and Docker runs a little built-in DNS server on networks like it — so each service is reachable by its service name, and the monitor URLs just become the names from the compose file.
The examples below use placeholder values — swap them for yours: homepage, grafana, prometheus, and uptime-kuma are service names from my compose file (use whatever yours are called); 3000, 3001, and 9090 are those services’ internal ports; demo_default is a Compose network name — Compose names it after your project folder, so check docker network ls for yours; monitor.homelab.lan and my-server are stand-ins for your machine’s hostname or IP. The rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.
| Before (broken) | After (works) |
|---|---|
http://localhost:3000 |
http://homepage:3000 |
http://localhost:9090 |
http://prometheus:9090 |
http://localhost:<published port> |
http://grafana:3000 |
The Grafana row deserves a second look: in your browser you use whatever published host port you mapped it to, but by name you use the port the service listens on inside its container — Grafana’s default is 3000, the same as Homepage’s, which is exactly why the two can’t both have host port 3000 and one gets remapped. The two vantage points really are different worlds, ports included.
Within minutes of the change, all three monitors went green. I set the check interval to 20 seconds — the minimum Uptime Kuma allows — purely to rebuild a respectable-looking history bar faster, and the wall of red scrolled away.
You can prove the whole thing to yourself in two commands, no Kuma required. From the host, ask from a container’s vantage point — first the wrong way, then the right way:
# The trap: from inside a fresh container, "localhost" is that container.
# This fails with "Connection refused" even while the service works in your browser:
docker run --rm busybox wget -qO- http://localhost:3000
# The fix: join the compose project's network and use the service name.
# Find the network name first with: docker network ls
docker run --rm --network demo_default busybox wget -qO- http://homepage:3000
One more habit from that morning that I’d recommend: before deleting the broken monitors, I checked their history. All 1,440 beats were failures — there was nothing worth keeping — but if the monitor had ever been green, that history is evidence, and deleting it mid-mystery destroys your timeline.
When the target really is on the host
Sometimes the thing you want to monitor genuinely runs on the host machine itself — not in a container. From inside a container, localhost still can’t see it, but Docker gives you a doorway: the special hostname host.docker.internal. On Docker Desktop it resolves automatically; on a Linux server you wire it up with the special host-gateway value, which Docker’s run reference maps to the host’s internal IP:
services:
uptime-kuma:
image: louislam/uptime-kuma:2
extra_hosts:
- "host.docker.internal:host-gateway"
Now a monitor URL like http://host.docker.internal:8080 reaches port 8080 on the host. Your quick decision table:
| Where the target lives | What to put in the monitor URL |
|---|---|
| Another container, same Compose project | The service name — http://homepage:3000 |
| A process on the Docker host itself | host.docker.internal (with host-gateway on Linux) |
| Another machine on your network | That machine’s LAN IP or hostname |
| The container itself (rarely what you want) | localhost — the only case where it’s right |
There’s also a blunter instrument: running the container with host networking (network_mode: host), which removes the network isolation entirely so the container shares the host’s network — then localhost really is the host. It works (on Linux, and on Docker Desktop 4.34+ with the setting enabled), but you give up the isolation and the tidy per-project DNS that make Compose networking pleasant. I’d reach for service names first every time.
What the wall of red taught me
- A monitor’s location is part of the check. “Is it up?” is really “is it reachable from here?” — and here was inside a container I’d never thought about. When a check disagrees with your browser, believe both: they’re honestly reporting from two different places.
- Solid red has a signature. Real outages flap. A bar that has never once been green, failing instantly with
connection refused, means the checker can’t reach the target — go read the monitor URL before you go debug the service. - The trap wears many hats. The same mistake breaks Arr apps pointed at
localhostdownload clients, healthchecks, and any two containers you’ve told to find each other at127.0.0.1. Once you’ve been bitten, you’ll recognize it everywhere — that’s the good news. - Monitoring the monitor matters. My 24 hours of false alarms were harmless because this was a demo box. On a real lab, a monitor checking the wrong thing is worse than no monitor — it’s confidence you haven’t earned. My self-healing Arr stack post walks the opposite failure: automation that reported fine while doing nothing.
What’s next
If you don’t have monitoring yet, start with the Uptime Kuma guide — it’s a five-minute setup, and now you know the one mistake to avoid on day one. If containers-finding-each-other still feels fuzzy, your first Docker Compose stack builds that mental model gently, and the Homepage dashboard guide is a nice place to practice service-name URLs on purpose.
Related posts:
- Uptime Kuma: Dead-Simple Homelab Monitoring Before You Touch Grafana — the tool in this story, set up right from the start
- Your First Docker Compose Stack — the mental model for services, networks, and why names beat IPs
- Docker in Proxmox: LXC or VM? — where your Docker host itself should live
- Homepage: One Dashboard for Your Whole Homelab — the service my broken monitor was supposed to be watching
- Grafana + Prometheus on Proxmox — the deeper monitoring stack, once up/down isn’t enough
- The Self-Healing Arr Stack: Why My Watchlist Silently Stopped Downloading — another field note about automation that fails silently
- Real Screenshots, Zero Leaks: My Disposable Demo Lab — the demo box from this story, and the screenshot pipeline it was built for
Sources: Docker networking documentation, Docker Compose networking, docker run reference — --add-host, Docker host network driver, network_namespaces(7), Uptime Kuma (GitHub).
Comments
Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.