A /health route that returns 200 OK from a process that can’t reach its database is the classic way a health check endpoint lies. The route answers, so everything watching it relaxes. Customers get error pages anyway.
The fix isn’t a smarter single endpoint. It’s admitting that three different callers ask three different questions, and giving each one its own answer.
Three Callers, Three Questions
Most writing on health checks stops at liveness and readiness. That leaves out the third caller, which is the one that pages you.
| Caller | Question it asks | What it does on failure |
|---|---|---|
| Orchestrator liveness probe | Is this process wedged beyond repair? | Kills and restarts the container |
| Load balancer or readiness probe | Can this instance take traffic right now? | Stops routing to it |
| External uptime monitor | Can a customer use the service from the internet? | Alerts a human |
The Kubernetes documentation is blunt about the first row. It says liveness probes should be used with caution and that getting them wrong “can lead to cascading failures”: containers restarted under high load, failed requests, and more work piled on the pods still standing. That happens when a liveness check tests the database. The database has a slow minute, every pod fails liveness at once, Kubernetes restarts all of them, and a slow minute becomes an outage.
So the three answers look like this:
/livezchecks only that the process can run code and respond. No database, no cache, no network calls. If this fails, restarting genuinely helps./readyzchecks what this instance needs to serve a request: its database connection pool, its cache client, any warm-up it must finish. Failing it takes one instance out of rotation without killing it./health(or/status) is the outside view. It confirms the parts a customer depends on actually work together, and it’s the only one of the three worth pointing an alert at.
What the Outside Health Check Endpoint Should Actually Check
The external endpoint is where people over-reach in the other direction. Checking every dependency sounds thorough until your email provider has a bad afternoon and your whole site reports itself down.
Put a dependency in the health check only if its failure means a customer can’t do the main thing. For most small SaaS and e-commerce apps that’s a short list:
- The primary database, with a real query rather than a connection ping.
SELECT 1proves the socket is open. Reading one row from a table your app uses proves the permissions, the schema, and the disk. - Anything on the request path you can’t degrade around. A session store you can’t log in without. The object storage your product pages render from.
- Nothing third-party that has its own fallback. If checkout can queue emails when your email API is down, the email API doesn’t belong here. Watch vendors separately; our piece on third-party API monitoring covers that wiring.
Two rules keep the deep check from becoming its own problem. Give every dependency check a short timeout, well under the timeout your monitor uses, so one hung connection produces a clear failure instead of a slow response. And cache the result for a few seconds if the endpoint is public, so it can’t be turned into a cheap way to hammer your database.
Let the Status Code Carry the Verdict
A monitor hitting your endpoint will make its decision from the HTTP status code before it reads a word of the body. Design for that.
The most thorough attempt at a standard here is the IETF draft Health Check Response Format for HTTP APIs. It never became an RFC (its last revision was October 2021 and it’s marked expired), but its rules are sound and plenty of APIs follow them. It defines a status field of pass, fail, or warn, a media type of application/health+json, and a mapping: pass must use a 2xx or 3xx code, fail must use a 4xx or 5xx code.
Note the quiet part: warn must also return 2xx or 3xx. A degraded service reporting warn looks exactly like a healthy one to anything that only reads status codes. Keep that in mind for the body check below.
In practice:
HTTP/1.1 503 Service Unavailable
Content-Type: application/health+json
Cache-Control: no-store
{
"status": "fail",
"checks": {
"database": { "status": "fail", "output": "query timeout after 2s" },
"sessions": { "status": "pass" }
}
}
Return 503 when failing. Returning 200 with "status": "fail" in the body hides the failure from anything that reads status codes, which is most things. Send Cache-Control: no-store too, and check that your CDN respects it. A cached 200 from an edge server will keep reporting healthy long after the origin has gone.
The Three Gotchas That Make a Monitor Say “Up” Forever
These come from how external monitors classify responses, and they catch people who got everything above right.
A redirect to a login page reads as healthy. Most monitors follow redirects and judge the final response. Ours follows up to 10 hops and treats any final 2xx or 3xx as up, which is the same range Kubernetes HTTP probes count as success. So if /health sits behind your auth middleware and redirects anonymous requests to /login, the monitor sees a perfectly good login page and reports the service up, every time, including while the database is down. Exempt the health route from auth redirects explicitly.
A 401 or 403 is not a failure. When an origin answers with 401, 403, 407, 418, 429 or 451, FlareWarden records the check as blocked rather than down. The origin answered, which proves it’s alive, but it refused to tell us anything. Blocked checks never alert. That’s the right call for a bot wall on a homepage, and it means a health check endpoint that demands an API key will never page you. If you need it private, allowlist the monitor instead of adding auth (see what to do when your uptime monitor is blocked by a firewall).
A warn hides inside a 200. Because the draft standard (sensibly) keeps warn in the 2xx range, a status-code check will never surface it. If you care about degraded states, you need a check that reads the body.
Wiring It Up in FlareWarden
Our uptime monitor docs recommend monitoring your homepage and a health endpoint, and the reason is the table at the top of this article. The homepage exercises the full stack a customer sees, including the CDN and front end. The health endpoint fails faster and more specifically when a backend dependency goes, because it checks that dependency directly instead of waiting for a page render to break.
Set it up as two monitors:
- An uptime monitor on
https://yourdomain.com/health. Keep the timeout above your endpoint’s own dependency timeouts, so a slow database shows up as a503with a reason rather than as a monitor timeout. Checks run from our probe cities in your monitor’s region, and a failure is re-checked from other cities before it becomes an incident, so one network blip between us and you doesn’t page anyone. - A content monitor on the same path, with a regex match on
"status":\s*"pass"so it doesn’t care how your JSON is spaced (or an absence match on"warn"if you’d rather be told about degradation specifically). Set its severity to Degraded, so awarnmarks the service impaired rather than down. One caveat, documented honestly on that page: content checks against a server that sends no ETag or Last-Modified header run at most every 5 minutes. The uptime monitor stays your fast signal; the content check is the richer one.
Our probes identify themselves with the User-Agent Uptime-Monitor/1.0, which is handy for keeping health check traffic out of your analytics and for writing that allowlist rule.
When a Single Endpoint Is Fine
All of this is overkill for a brochure site on managed hosting. If there’s no database you control and no orchestrator restarting anything, monitor the homepage, add a content check that the page still says what it should, and skip the custom endpoint entirely.
The split starts earning its keep the moment two things are true: something automatic acts on your health check (a load balancer or Kubernetes), and a human gets woken up by one. Those two callers need different answers, and one endpoint can’t give both. The same logic applies further down the stack, which our monitoring anti-patterns piece covers in more general terms.
Key Takeaways
- Three callers, three endpoints. Liveness for restarts, readiness for routing, and an outside health check endpoint for alerting. Mixing them up is how a slow database becomes a restart storm.
- Liveness checks nothing external. The Kubernetes docs themselves warn that a badly scoped liveness probe causes cascading failures.
- The status code is the verdict. Return
503on failure;200with a failing body is invisible to most monitors. - Exempt
/healthfrom auth. A redirect to a login page reads as up, and a 401 is classified as blocked, not down. Either way you’ll never be paged. - Read the body if you care about
warn. Degraded states live in the 2xx range, so pair the uptime monitor with a content check.
Point an outside monitor at your health endpoint and find out before your customers do. Start monitoring free with FlareWarden: 15 monitors, uptime and content checks, confirmed from multiple locations before anyone is paged.