The 404 That Wasn't: Debugging Traefik, Docker, and a Stubborn Health Check

#docker #traefik #debugging #dokploy

I deployed an Astro app behind Traefik on Dokploy and kept getting a 404. The server was running. The logs looked fine. The routing config was correct. It took four rounds of debugging to find the real culprit — a health check that was failing silently because of how Alpine Linux resolves localhost.

The Setup

Astro app, Node adapter, Docker Compose, deployed via Dokploy (a self-hosted PaaS that manages Traefik for you). Everything looked green. The container was running, logs said:

[@astrojs/node] Server listening on http://localhost:4321

But visiting the domain returned a flat 404 from Traefik.

Round 1: “The server isn’t binding to 0.0.0.0”

My first instinct. The server must be binding to localhost instead of 0.0.0.0, making it unreachable from outside the container.

I changed the Dockerfile CMD to force it:

CMD ["sh", "-c", "HOST=0.0.0.0 node dist/server/entry.mjs"]

Redeployed. Same 404. Same log message.

Lesson: The [@astrojs/node] log message always displays localhost — it’s cosmetic. The actual binding address comes from process.env.HOST.

Round 2: “It’s a Traefik routing issue”

I checked the response headers:

HTTP/2 404
content-type: text/plain; charset=utf-8

No Traefik headers at all. This meant Traefik didn’t have a route for my domain. I checked the container labels and found everything was configured correctly — Host() rule, port, TLS, network. Traefik should have been routing.

Round 3: “The server isn’t actually listening”

I ran the health check inside the container:

docker exec marginalia wget --spider http://localhost:4321/
wget: can't connect to remote host: Connection refused

Connection refused! But netstat showed the server listening on 0.0.0.0:4321. And testing with 127.0.0.1 explicitly worked:

docker exec marginalia wget -qO- http://127.0.0.1:4321/ | head -5
<!DOCTYPE html><html lang="en">...

Round 4: The Real Culprit

The difference: the health check used localhost, my manual test used 127.0.0.1.

In Alpine Linux (which the node:20-alpine image uses), wget resolves localhost to the IPv6 address ::1 first. But my Node server was binding to 0.0.0.0IPv4 only. So the IPv6 connection was refused, and Alpine’s wget didn’t fall back to IPv4.

The Docker health check was failing (49 consecutive failures), marking the container as unhealthy. And Traefik respects Docker health status — it won’t route traffic to unhealthy containers.

The fix was one line:

# Before
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4321/"]

# After
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:4321/"]

Redeployed. Health check passed. Traefik picked up the container. Site loaded.

Takeaways

  1. Always use 127.0.0.1 instead of localhost in Alpine health checks. Alpine’s wget prefers IPv6 for localhost, which breaks if your server only listens on IPv4.

  2. Traefik silently skips unhealthy containers. A misconfigured health check doesn’t just affect monitoring — it takes your app offline with no error message.

  3. 0.0.0.0 in Node.js is IPv4 only. If you want dual-stack, bind to :: instead. But for most Docker setups, IPv4 is fine — just make sure your health checks match.

  4. Debugging order matters. I started with the binding address, then routing, then connectivity. The actual issue was the health check — which I almost didn’t check because the container status showed “running.”