When a Docker Service Looks Healthy but Times Out Externally: Uptime Kuma, WARP, and Return-Path Routing
This incident happened on a new VPS where the target service was deployed with Docker and exposed on port 8000. The monitored health-check URL was:
http://129.150.42.71:8000/health
At first glance, the symptoms were confusing:
- The Docker container was running normally
- The container health check was passing
- Accessing
127.0.0.1:8000/healthon the host returned200 OK - Uptime Kuma kept showing the monitor as down
- Direct access from outside the server timed out
This is exactly the kind of situation that makes people suspect application code, the health endpoint, or Docker port publishing first. But none of those turned out to be the real issue.
Start from the observable facts
The first round of checks focused on three basic questions.
1. External access was genuinely timing out
From an external machine:
curl http://129.150.42.71:8000/health
The request timed out.
2. Local access on the server worked
After logging into the VPS:
curl http://127.0.0.1:8000/health
It returned:
{"status":"healthy","version":"1.0.0"}
That told us the application itself was alive.
3. Docker port publishing looked correct
Inspecting the container showed:
0.0.0.0:8000->8000/tcp
So this was not the classic case where Docker only publishes to 127.0.0.1. The service was bound to all IPv4 addresses on the host.
At this point, the app, the container, and the port mapping all looked fine.
The critical clue
The real turning point came from inspecting host networking. This VPS had wgcf enabled, which meant Cloudflare WARP was running over WireGuard.
The routing policy showed a very important fact: the default outbound path had been taken over by WARP.
That changes the story.
When a public request hit 129.150.42.71:8000:
- The packet reached the server
- Docker forwarded it to the container
- The container produced a response
- But the reply did not leave through the expected public interface; policy routing pushed it into the WARP tunnel instead
From the client side, this does not look like an HTTP failure. It looks like the worst kind of problem: a timeout with no response.
Why this kind of issue is easy to misread
Because most surface-level indicators still say “healthy”:
- Container health checks are green
- Local curl works
ss -ltnpshows0.0.0.0:8000listening- The host firewall appears to allow
8000/tcp
But those facts only prove that requests can arrive and the application can process them. They do not prove that replies are leaving through the correct path.
On machines using WARP, WireGuard, policy routing, or multiple uplinks, the return path is often where things actually go wrong.
How the root cause was confirmed
The most valuable step in this investigation was not another docker ps. It was packet capture.
Capturing traffic for port 8000 on the server showed that:
- The external SYN packet entered the server
- Docker forwarded it to the container
- The container sent a SYN-ACK back
- But the return traffic did not leave through the expected public interface; it was associated with the
wgcfpath instead
That immediately narrowed the problem from “application layer” down to “return-path routing”.
In other words:
The service was not failing to respond. It was responding, but the response was getting lost on the way back.
The deeper cause: the existing bypass fix was only half-correct
This server already had a helper script intended to bypass WARP for Docker traffic:
/usr/local/sbin/fix-warp-docker-bypass.sh
The idea was reasonable: mark incoming connections with CONNMARK, then restore that mark on established traffic so replies avoid the WARP default route.
The problem was timing.
The script restored the connection mark in the mangle/FORWARD chain.
For this 8000 Docker flow, that was too late.
Routing decisions happen earlier. By the time the packet reached FORWARD, the reply path had already been chosen, so restoring the mark there did not actually fix the outbound route.
That produced the broken behavior:
- The inbound connection was marked
- But the reply did not regain the mark early enough for routing
- The response still went out through WARP
The fix
This did not require reinstalling Docker or changing application code.
The key idea was simple:
Restore the mark for established return traffic earlier, in mangle/PREROUTING, so the packet carries the correct mark before route selection happens.
After the change, the logic became:
- Mark new inbound connections in
PREROUTINGbased on the destination port - Restore the connection mark for established return traffic in
PREROUTINGbased on the source port
Why this approach was useful:
- No application changes
- No Docker Compose changes
- No container restart required
- Only the return path was corrected
The fix was then written back into:
/usr/local/sbin/fix-warp-docker-bypass.sh
And we confirmed that it was attached to:
wg-quick@wgcf.service -> ExecStartPost
That matters because it makes the solution persistent. If the server or WARP restarts later, the routing fix will be re-applied automatically.
Verification after the fix
After applying the rule change, an external check succeeded:
curl http://129.150.42.71:8000/health
It returned:
HTTP/1.1 200 OK
And:
{"status":"healthy","version":"1.0.0"}
At that point, Uptime Kuma could recover automatically on its next monitoring cycles.
What is worth remembering from this incident
1. A healthy container does not imply public reachability
A Docker health check only proves that the service works inside the container context. It does not prove that an outside client can complete the full request-reply path.
2. A successful local curl does not imply external success
If curl 127.0.0.1 works but curl public-ip fails, the issue is often in host networking, NAT, firewalling, cloud security policy, or routing.
3. Timeouts usually smell more like path issues than app errors
If the application is failing, you often get a concrete HTTP status code. If you get silence and timeouts, prioritize checking:
- Firewalls
- Cloud security groups
- Bind addresses
- NAT
- Policy routing
- Multi-uplink networking
4. Docker plus WARP/WireGuard needs extra attention on the reply path
This is a very typical failure mode. Marking only inbound traffic is not enough. The reply must recover the right mark early enough to influence route selection.
5. Packet capture beats guessing
Once you have already proven that the app is healthy, the container is healthy, and the port is listening, it is often better to stop circling around application-layer guesses and capture packets instead.
A good capture quickly answers:
- Did the request arrive?
- Did the response leave?
- Which interface did the packet use?
That turns a vague mystery into a closed evidence loop.
Final thought
This was not really a Docker bug, and it was not an Uptime Kuma false alarm. The real issue was that WARP changed the default outbound path, and Docker-exposed traffic ended up with the wrong return route.
If you ever run into a case where the container is healthy, the port is listening, local access works, but outside traffic still times out, and the machine also runs WARP or WireGuard, check policy routing and the return path early.
Sometimes the service is not broken at all. It just cannot find its way home.