Menu

Health Check Endpoint

Good to know

/api/health is new in v4.0.0. It needs no configuration and works as soon as you deploy.

The contract

GET /api/health  ->  200  { status: 'ok',    at, uptimeSec, checks }
                     503  { status: 'error', at, uptimeSec, checks }

A sample response:

{
  "status": "ok",
  "at": "2026-09-12T08:30:00.000Z",
  "uptimeSec": 86400,
  "checks": {
    "database": { "status": "ok", "latencyMs": 12 },
    "redis": { "status": "skipped", "latencyMs": 0 }
  }
}

Each entry in checks has one of three statuses:

StatusMeaningAffects the overall result
okThe probe round-tripped successfullyNo
downThe probe failed or timed outYes — the whole response turns 503
skippedThe dependency is not configured in this deploymentNo

Only down turns the probe red. A monitor therefore only ever needs the HTTP status code and never has to parse the body.

skipped is a deliberate design point: the boilerplate ships without Redis, and a deployment with no Redis is perfectly healthy — it must not page anyone.

What is probed today

NameProbeWhen it is skipped
databaseselect 1The database is not enabled
redisGET health:probe (read-only, never written)No Redis client is configured

The Redis probe uses a fixed read-only key. Whether that key exists is irrelevant — what is measured is the round-trip, not the value.

Both clients are already "null when unconfigured", so their own existence is the configuration truth; the code never repeats an env-var list.

Two design decisions

Deliberately public, deliberately silent

The endpoint takes no secret so any prober can reach it. Load balancers, uptime monitors and orchestrators all share one contract, and requiring a credential would make that awkward for at least one of them.

The price is that it returns no detail: no error strings, no version, no hostname. Failure reasons go to the logger only — at warn, not error, because a monitor re-probes every few seconds, so a real outage at error level would fire one Sentry exception per probe while the 503 has already raised the alarm.

Every probe is capped

const CHECK_TIMEOUT_MS = 3_000

A probe is a network round-trip, not a computation. Three seconds is the entire budget each dependency gets — a timeout degrades into down instead of a hung TCP connection holding the request open until the platform kills it.

All probes run in parallel, so total latency is the slowest dependency, not their sum.

Wiring it into monitoring

Uptime monitoring (UptimeRobot / BetterStack / Pingdom, …)

  • URL: https://your-domain/api/health
  • Method: GET
  • Expected status: 200
  • Suggested interval: 1–5 minutes

No keyword matching or JSON parsing needed — the status code is enough.

Container orchestration (Docker / Kubernetes)

docker-compose.yml:

healthcheck:
  test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 40s

Kubernetes:

livenessProbe:
  httpGet:
    path: /api/health
    port: 3000
  initialDelaySeconds: 40
  periodSeconds: 30
readinessProbe:
  httpGet:
    path: /api/health
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 10

Give timeout at least 5 seconds: a single dependency's probe budget is already 3 seconds, and parallel scheduling plus network overhead on top means a tighter setting will kill healthy instances.

Coolify / Dokploy

Both expose a health-check setting in the application config. Point it at /api/health and expect status 200.

Load balancers and reverse proxies

Nginx upstream health checks and cloud ALB/NLB target group health checks point at /api/health the same way, judged on HTTP 200.

Adding a dependency

Append one entry to the CHECKS array in app/api/health/route.ts. Nothing else branches:

app/api/health/route.ts
const CHECKS: HealthCheck[] = [
  {
    name: 'database',
    probe: isDatabaseEnabled ? () => db.execute(sql`select 1`) : null,
  },
  {
    name: 'redis',
    probe: redisClient ? () => redisClient.get(REDIS_PROBE_KEY) : null,
  },
  // New: probe returns a Promise — resolving means healthy, throwing means down.
  // A null probe means this deployment does not configure the dependency (skipped).
  {
    name: 'r2',
    probe: r2Client ? () => r2Client.headBucket({ Bucket: bucketName }) : null,
  },
]

Three rules:

  1. A resolving probe is healthy, a throwing one is not, and the return value is ignored
  2. A null probe means "this deployment does not configure it" and reports skipped rather than a failure
  3. Probes must be read-only. A health check is called at high frequency, and any write is a liability

FAQ

Q: Why 503 rather than 500 when the database is down?

503 Service Unavailable means "temporarily unavailable", which is exactly the signal load balancers and orchestrators expect — they pull the instance out of rotation and wait for it to recover. 500 says "this request errored", which is the wrong semantics.

Q: Should the endpoint sit behind authentication?

Not recommended. It returns nothing sensitive, and adding auth complicates integration with most monitoring tools. If compliance requires it, restrict by source IP at the reverse proxy instead.

Q: Can I use it to decide whether a deploy succeeded?

Yes. It serves as both a liveness probe (the process is alive) and a readiness probe (the dependencies answer). A deploy script can poll it until it returns 200 before shifting traffic.

Q: How is this different from the GET on /api/cron/credits?

That GET is only the cron route's own health check; it probes no dependency and serves a different purpose entirely. The cron's real entry point is the POST — see Scheduled Tasks.