A health check endpoint is a URL, usually /health, that tells a monitor or load balancer whether your application can serve requests. It runs a few fast checks against the things the app depends on, such as the database, the cache and free disk space, and answers with HTTP 200 and {"status":"ok"} when everything works, or HTTP 503 and the name of the failing check when something does not. Keep it fast, cheap, uncached and free of secrets, then point an uptime monitor at it that validates the response, not just the status code.
This guide covers the difference between liveness and readiness, what to check, the response format, working examples for PHP with Laravel, Node with Express and Python with Flask, and how to monitor the endpoint once it exists.
Why the homepage is not a health check
Most teams start by monitoring the homepage. It is a reasonable first step, but the homepage is often served from a CDN or page cache, so it keeps answering 200 while the database is down and nobody can log in or check out. A dedicated endpoint bypasses the cache and exercises the dependencies directly. It also gives you one predictable URL with a small, stable response that is easy to validate.
Liveness versus readiness
Container platforms such as Kubernetes split health into two questions, and the split is useful everywhere:
- Liveness: is the process alive and not stuck? If not, restart it. A liveness check should not test the database or other services. If the database goes down and every app instance fails its liveness check, the platform restarts all of them, which does not fix the database and makes recovery slower.
- Readiness: can this instance serve real requests right now? If not, stop sending it traffic. This is where dependency checks belong.
A common layout is /livez or /up for liveness, returning 200 as long as the app boots, and /health or /readyz for readiness. For external uptime monitoring, the readiness endpoint is the interesting one, because it answers the question your customers care about: does the app work?
What to check
Check the dependencies without which a normal request would fail, and nothing more:
- Database: run
SELECT 1on the same connection pool the app uses. It proves the server is reachable and accepting queries. - Cache or session store: write and read a short-lived key in Redis or Memcached. If sessions live there, login depends on it.
- Disk: check free space on the volume the app writes to, for uploads, logs or sessions. Fail when it drops under a margin, for example 10 percent.
- Queue: check that the queue backend answers, and optionally that the oldest waiting job is not older than a limit you choose. A growing backlog means workers have stopped.
Be careful with third-party APIs. If your endpoint calls a payment provider on every check and the provider is slow, your health check fails even though your own app is fine, and a load balancer may pull every instance out of rotation. Monitor third parties with their own monitors instead, so an alert tells you whose problem it is.
Keep it fast and cheap
A health check runs every minute from every monitor and load balancer, around the clock. It must never become the thing that overloads the app.
- Give each dependency check its own short timeout, for example one or two seconds, so one hanging service does not hang the endpoint.
- Run checks in parallel where your language makes that easy.
- Use trivial queries. Never count rows in a large table or run a report.
- If many checkers hit the endpoint, cache the result in memory for a few seconds.
- Send
Cache-Control: no-storeand exclude the path from page caches and CDNs, so you never see a cached “ok”. - Skip sessions, cookies and authentication middleware the endpoint does not need.
Do not leak secrets
A public health endpoint is visible to anyone who guesses /health. Return the name and state of each check and nothing else. Do not include exception messages, connection strings, hostnames, internal IP addresses, software versions or environment variables. Log the detailed error on the server instead. If you want a richer diagnostic view, put it on a separate path behind authentication or restrict it to your internal network.
Response shape and status codes
Keep the JSON small and stable:
{
"status": "ok",
"checks": {
"database": "ok",
"cache": "ok",
"disk": "ok",
"queue": "ok"
}
}
When a check fails, set its value to "fail", set the top-level status to "fail" and change the HTTP status code:
- 200 OK when every required check passes.
- 503 Service Unavailable when a required check fails. It is the code load balancers and monitors expect for “temporarily cannot serve”.
- Optionally, 200 with
"status":"degraded"for an optional dependency that is down while the core still works. Decide up front which checks are required.
Avoid returning 200 with "status":"fail" in the body. Many tools only read the status code. See HTTP status codes for monitoring for how each code is usually treated.
Example: PHP and Laravel
Recent Laravel versions ship a simple /up route that confirms the app boots, which makes a good liveness check. For readiness, add a route that checks the dependencies:
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
Route::get('/health', function () {
$checks = [];
try {
DB::select('SELECT 1');
$checks['database'] = 'ok';
} catch (\Throwable $e) {
report($e);
$checks['database'] = 'fail';
}
try {
Cache::put('health-check', 'ok', 10);
$checks['cache'] = Cache::get('health-check') === 'ok' ? 'ok' : 'fail';
} catch (\Throwable $e) {
report($e);
$checks['cache'] = 'fail';
}
$path = storage_path();
$checks['disk'] = disk_free_space($path) / disk_total_space($path) > 0.10 ? 'ok' : 'fail';
$healthy = ! in_array('fail', $checks, true);
return response()
->json(['status' => $healthy ? 'ok' : 'fail', 'checks' => $checks], $healthy ? 200 : 503)
->header('Cache-Control', 'no-store');
});
Register it outside middleware groups that start sessions, and set a short connection timeout for the database in your config.
Example: Node and Express
const withTimeout = (promise, ms) =>
Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
const check = (fn) => withTimeout(fn(), 2000).then(() => 'ok', () => 'fail');
app.get('/health', async (req, res) => {
const [database, cache] = await Promise.all([
check(() => pool.query('SELECT 1')),
check(() => redis.ping()),
]);
const checks = { database, cache };
const healthy = Object.values(checks).every((v) => v === 'ok');
res
.set('Cache-Control', 'no-store')
.status(healthy ? 200 : 503)
.json({ status: healthy ? 'ok' : 'fail', checks });
});
The checks run in parallel, each with a two-second limit, so the endpoint answers within about two seconds even when a dependency hangs.
Example: Python and Flask
import shutil
from flask import jsonify
from sqlalchemy import text
@app.get("/health")
def health():
checks = {}
try:
db.session.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception:
app.logger.exception("health: database")
checks["database"] = "fail"
usage = shutil.disk_usage("/")
checks["disk"] = "ok" if usage.free / usage.total > 0.10 else "fail"
healthy = all(value == "ok" for value in checks.values())
body = {"status": "ok" if healthy else "fail", "checks": checks}
return jsonify(body), 200 if healthy else 503, {"Cache-Control": "no-store"}
Add a Redis check the same way if you use it for sessions or a task queue.
Test the failure path
A health check that has never failed has never been tested. In staging, stop the database or point the app at a wrong cache host and confirm that the endpoint returns 503 within your timeout and names the right check. Then confirm the response contains no error text. It takes ten minutes and tells you the endpoint will be honest when it matters.
Monitoring the endpoint
An endpoint nobody checks from outside is only half the job. Point an HTTP monitor at it from outside your network, so it also catches DNS, TLS, firewall and load balancer problems:
- Monitor
https://lumenanalytics.io/healthwith GET every minute. - Expect status 200 and validate that the body contains
"status":"ok". A proxy error page that happens to return 200 then still fails the check. - Send a custom user agent header, so monitoring traffic is easy to filter out of analytics and logs.
- Watch the response time history. A health check that creeps from 40 ms to 900 ms is an early warning of a struggling database.
- Route the alert to the people who own the service.
Uptimecheck API monitoring supports custom methods, headers and basic auth and validates the expected response, so this setup takes a minute. Keep a regular uptime monitor on the homepage and the most important conversion page next to it; the API monitoring checklist covers the other endpoints worth watching.
Queue workers and scheduled tasks often run outside the web process, so a health endpoint cannot see whether they stopped. Give them heartbeats instead: the worker pings a URL every few minutes, and a missing ping raises the alert.
Health check endpoint checklist
- Separate liveness (process alive) from readiness (dependencies work).
- Check the database, cache, disk and queue the app needs to serve a request.
- Give every check a short timeout and keep queries trivial.
- Return 200 when healthy and 503 when a required check fails.
- Return check names and states only. No errors, versions or hostnames.
- Disable caching for the path.
- Monitor it every minute from outside, with response validation.
Uptimecheck monitors health endpoints, APIs, servers and scheduled jobs in one account. Start a 14-day free trial, no credit card required.