Health Check Endpoint
Good to know
/api/healthis 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:
| Status | Meaning | Affects the overall result |
|---|---|---|
ok | The probe round-tripped successfully | No |
down | The probe failed or timed out | Yes — the whole response turns 503 |
skipped | The dependency is not configured in this deployment | No |
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
| Name | Probe | When it is skipped |
|---|---|---|
database | select 1 | The database is not enabled |
redis | GET 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_000A 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: 40sKubernetes:
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 40
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10Give 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:
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:
- A resolving
probeis healthy, a throwing one is not, and the return value is ignored - A
nullprobe means "this deployment does not configure it" and reportsskippedrather than a failure - 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.