Health Check Failing but the App Works
A health check failing on deployment while the app runs fine locally usually comes down to five causes. Work through binding, path, port, timing and dependencies in the order that finds it fastest.
There is a particular flavour of stuck where a health check failing deployment blocks every release while the application is, by every measure you can reach, completely fine. It runs locally. It runs in Docker locally. The logs show it listening. And the platform reports failure after failure, sometimes twenty in a row, with no request ever appearing in your access log.
That last detail is the important one, and it narrows the field immediately. If your application never logged the request, the check never reached your application — so nothing about your application code is going to explain it.
Here are the five causes, in the order that finds the problem fastest.
1. You are bound to localhost
This is the single most common cause, and it explains the "no traffic ever arrives" symptom exactly.
Inside a container, 127.0.0.1 means this container's own loopback. A health check arriving from outside the container cannot reach it. The process is listening, your logs say so, and the socket is unreachable from anywhere that matters.
// Unreachable from outside the container
app.listen(3000, '127.0.0.1')
// Correct
app.listen(3000, '0.0.0.0')
Frameworks differ in their defaults, and several of them changed the default between major versions. Check what your framework actually binds rather than what you remember it binding.
# Confirm from inside the running container
dockup exec "ss -ltn || netstat -ltn" my-project/my-api
If the listening address is 127.0.0.1:3000 rather than 0.0.0.0:3000, you have found it and nothing else on this list matters.
2. The port the platform probes is not the port you serve
Two ports are involved and they are easy to conflate: the port your process listens on inside the container, and the port the platform routes to. If your app reads PORT from the environment and you hardcoded 3000 somewhere in a Dockerfile, those two can disagree silently.
The reliable pattern is to let the platform tell you:
const port = process.env.PORT || 3000
app.listen(port, '0.0.0.0')
Then set the service's port once, in the platform, and stop maintaining the number in two places.
3. The path returns something other than a success
A health check path is matched exactly, and a surprising number of failures are a redirect. If your app redirects /healthz to /healthz/, or forces HTTPS with a 301, a checker that treats only 2xx as success will fail every time while a browser follows the redirect and shows you a working page.
Three specific traps:
- Trailing slash redirects.
/healthz→/healthz/is a 301. - Forced HTTPS. The internal check usually arrives over plain HTTP on loopback. An unconditional HTTPS redirect fails it.
- Auth middleware. A global authentication guard that runs before routing will return 401 for the health path too.
Exclude the health path from auth and from HTTPS enforcement explicitly. It is the one route that should be boring.
4. The check is faster than your cold start
If the check fails a few times and then passes, or fails on deploy and passes when you retry, this is timing rather than configuration.
The budget you need is not one attempt — it is interval × retries. An application that takes twelve seconds to connect to its database and warm a cache needs a total budget above twelve seconds, or you will fail every release and eventually turn the gate off, which removes the only thing standing between a broken build and your users.
dockup info my-project/my-api --json | grep -A6 healthCheck
Set the timeout above your slowest legitimate single attempt, and set retries so that interval × retries comfortably exceeds your slowest legitimate boot. Measure the boot rather than guessing it — the logs have timestamps.
5. The application is genuinely not ready
The last case is the one the check exists for: your app started, could not reach a dependency, and is retrying. It has not crashed, so nothing restarts. It cannot serve, so the check fails. The system is working exactly as designed and telling you that this release should not receive traffic.
The way to tell this apart from the other four is that your application logged the request and answered with a non-2xx. If the request appears in your logs, causes 1 through 3 are eliminated.
The diagnostic order that saves time
# 1. Did the request reach the app at all?
dockup logs my-project/my-api --follow
# 2. What is the process actually bound to?
dockup exec "ss -ltn || netstat -ltn" my-project/my-api
# 3. Does the path answer from inside the container?
dockup exec "curl -si localhost:3000/healthz" my-project/my-api
# 4. What is the gate configured to expect?
dockup info my-project/my-api --json
Step 3 is the one that resolves most of these. A curl from inside the container removes every network variable at once: if it returns 200 there and the platform still fails, the problem is the address or the port, not the app. If it returns a 301 or a 401, you have found your cause without touching the platform at all.
Why the gate is worth keeping
It is tempting, after the fourth failed deploy, to disable the health check and get the release out. It is worth remembering what you are turning off.
On Dockup the health gate is the thing that keeps a broken release away from your users. The new version is built and started while the current one keeps serving; traffic only moves once the new one answers. Turn the gate off and you have re-enabled the failure mode where a container that starts and cannot work replaces one that was fine.
A check that fails for four releases in a row is annoying. A check that passes unconditionally is a check that will not stop the deploy that matters.
Frequently asked questions
Why does the health check fail when the app works locally?
Almost always because the container binds to 127.0.0.1 instead of 0.0.0.0. Locally you connect through the same loopback; from outside the container that address is unreachable.
Should the health endpoint require authentication? No. Exclude it from global auth middleware, or the checker gets a 401 and the deploy fails while the app is fine.
What timeout should I use? Longer than your slowest legitimate single attempt, with retries covering your slowest legitimate cold start. Read the boot time out of your logs rather than guessing.
Is it safe to disable the health check to unblock a release? It unblocks the release and removes the protection that stops a broken version taking traffic. Fix the check instead — in most cases the cause is a bind address or a redirect, and it takes minutes.
