Guides 7 min read

How to Build a Health Check Endpoint (and Monitor It)

A health check endpoint tells monitors and load balancers whether your app can serve requests. What to check, how to keep it fast and safe, code for Laravel, Express and Flask, and how to monitor it.

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 1 on 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-store and 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:

  1. Monitor https://lumenanalytics.io/health with GET every minute.
  2. 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.
  3. Send a custom user agent header, so monitoring traffic is easy to filter out of analytics and logs.
  4. Watch the response time history. A health check that creeps from 40 ms to 900 ms is an early warning of a struggling database.
  5. 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

  1. Separate liveness (process alive) from readiness (dependencies work).
  2. Check the database, cache, disk and queue the app needs to serve a request.
  3. Give every check a short timeout and keep queries trivial.
  4. Return 200 when healthy and 503 when a required check fails.
  5. Return check names and states only. No errors, versions or hostnames.
  6. Disable caching for the path.
  7. 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.

Questions

What should a health check endpoint return?

A small JSON body with an overall status and the state of each check, with HTTP 200 when everything required works and HTTP 503 when a required check fails.

Should a health check test the database?

The readiness check should, with a trivial query such as SELECT 1 and a short timeout. A liveness check should not, because a database outage would then restart every app instance.

Is it safe to make a health endpoint public?

Yes, if it returns only check names and states. Keep error messages, versions, hostnames and connection details out of the response and log them on the server instead.

How often should a health check endpoint be monitored?

Every minute for production services. Validate that the body contains the expected status as well as the status code, so a cached or proxy error page does not pass.

Keep reading

Guides 7 min read

HTTP Status Codes Cheat Sheet for Website Monitoring

Which HTTP status codes mean your site is down, which are fine and which depend on the endpoint. A monitoring-focused cheat sheet covering 1xx to 5xx, redirects, 401, 403, 429 and 5xx errors.

Read

Guides 6 min read

How to Reduce False Alerts in Uptime Monitoring

False alerts teach teams to ignore monitoring. How to cut the noise: confirm failures from a second location, set sensible timeouts, validate the right URL and route alerts to the people who can act.

Read

Guides 4 min read

API Monitoring Checklist: What to Check Beyond a 200 OK

An API can return 200 and still be broken. A practical checklist for API monitoring: methods, headers, request bodies, authentication, expected responses and response time thresholds.

Read

Downtime is obsolete.

Set up your first monitor in under a minute and know before your customers do.

14-day free trial · No credit card required

Start free trial