Health Checks
One option gives a Vercube application the probes Kubernetes, a load balancer or any orchestrator expects, and a shutdown that does not drop requests during a rolling deploy:
import { defineConfig } from '@vercube/core';
export default defineConfig({
health: true,
});
curl -s localhost:3000/_health/live
# {"status":"ok"}
curl -s localhost:3000/_health/ready
# {"status":"ok","checks":{"queue:default":{"status":"ok"}}}
curl -s localhost:3000/_health/startup
# {"status":"ok"}
Health is off by default: an application that does not set health gets no routes and no overhead.
The three probes
| Endpoint | 200 when | 503 when |
|---|---|---|
/_health/live | the process serves requests | never; nothing is checked |
/_health/ready | started, not shutting down, every critical check passes | starting, draining, or a critical check fails |
/_health/startup | the app.onStart() hooks have finished | they are still running |
Liveness checks nothing on purpose. A database outage should take the pod out of the load balancer (readiness), not make Kubernetes restart a healthy process in a loop (liveness).
Every response is JSON with cache-control: no-store, and the probes answer HEAD too. They skip application-wide middlewares, so a global authentication or tenancy middleware never locks the orchestrator out, and request logging does not write a line per probe.
Checks
Readiness runs every registered check in parallel. A check passes when it resolves and fails when it throws, rejects, returns { status: 'down' }, or runs past its timeout:
const app = await createApp();
app.health.register({
name: 'db',
critical: true, // false: reported, but readiness stays 200 ("degraded")
timeout: 1000,
check: () => pool.query('SELECT 1'),
});
await app.listen();
Checks only run when something asks for readiness, and the report is reused for cacheTtl milliseconds (1000 by default), so many replicas probing every few seconds still cost one round trip per backend per second. Registering a name again replaces the earlier check. app.health exists whether or not health is set, so modules register their checks either way; the option only adds the routes.
Checks for one application can also live in the config:
export default defineConfig({
health: {
checks: [
{
name: 'payments-api',
critical: false,
check: async () => {
const response = await fetch('https://payments.internal/ping');
if (!response.ok) {
throw new Error(`payments-api answered ${response.status}`);
}
},
},
],
},
});
Checks from modules
The modules register their own checks, there is nothing to write for them:
| Check | Registered by | What it does |
|---|---|---|
queue:<mount> | QueuePlugin, for BullMQ, RabbitMQ and Kafka mounts | a Redis read, opening a channel, describeCluster |
scheduler:store | SchedulerPlugin, for the Redis and Postgres stores | PING, SELECT 1 |
storage:<mount> | StorageManager, for S3 mounts | HeadObject, or HeadBucket |
In-memory drivers (the memory queue, the memory store, MemoryStorage) have no backend that can be down and get no check. The cache keeps its entries in a storage mount, so the check of that mount covers it.
Each one takes a health option: false registers nothing, { critical: false } keeps it in the report without failing readiness, and timeout overrides the default:
export default defineConfig({
health: true,
plugins: [
[QueuePlugin, {
strategies: [{ strategy: BullMQStrategy, initOptions }],
health: { critical: false },
}],
[SchedulerPlugin, {
store: { store: RedisStore, initOptions },
health: false,
}],
],
});
await storage.mount({
name: 'cache',
storage: S3Storage,
initOptions,
health: { critical: false },
});
Writing a check for your own module
A module registers its check where it sets itself up, usually in its plugin. Accept the same health option the built-in modules take, and read it with resolveModuleHealth() so false, true and { critical, timeout } behave the same everywhere:
import { BasePlugin, resolveModuleHealth } from '@vercube/core';
import type { App, HealthTypes } from '@vercube/core';
interface SearchOptions {
url: string;
health?: HealthTypes.ModuleOption;
}
export class SearchPlugin extends BasePlugin<SearchOptions> {
public override async use(app: App, options: SearchOptions): Promise<void> {
app.container.bindInstance(SearchClient, new SearchClient(options.url));
const health = resolveModuleHealth(options.health);
if (health) {
app.health.register({
name: 'search',
...health,
check: () => app.container.get(SearchClient).ping(),
});
}
}
}
Make the check a real round trip (a PING, a SELECT 1, a HEAD) rather than a flag the client last saw, and keep it cheap: it runs at most once per cacheTtl, on every replica. A module that must not depend on @vercube/core at runtime can inject the registry by the string key 'vercube:health', the way @vercube/storage does.
Details and the token
By default (details: 'summary') the readiness response names each check and its status, never an error message, which could reveal a host name or a credential problem. details: 'none' returns only the overall status.
The full report, with durations, error messages and whatever a check returned as details, goes to requests carrying the token:
export default defineConfig({
health: { token: process.env.HEALTH_TOKEN },
});
curl -s -H "Authorization: Bearer $HEALTH_TOKEN" localhost:3000/_health/ready | jq
{
"status": "degraded",
"timestamp": "2026-11-07T10:00:00.000Z",
"checks": {
"queue:default": {
"status": "ok",
"critical": true,
"duration": 1.8
},
"storage:cache": {
"status": "down",
"critical": false,
"duration": 1000.4,
"error": "Timed out after 1000ms"
}
}
}
details: 'full' without a token fails the boot. With a token it changes nothing: requests without the token still get the summary.
Paths
path moves all three endpoints, endpoints moves or removes each one on its own. An override is a full path and ignores path:
export default defineConfig({
health: {
endpoints: {
live: '/livez',
ready: '/internal/k8s/ready',
startup: false,
},
},
});
The paths are checked when the application starts. An empty or relative path, two endpoints on one path, or an application route registered on a probe path fails the boot with a message naming both, rather than one of them silently shadowing the other.
The probes are left out of telemetry, so traces and the devtools Requests panel are not buried under one request per probe per replica. Set telemetry: true to keep them. Only the probe paths themselves are excluded, an application route below one still produces traces.
Shutting down without dropping requests
During a rolling update Kubernetes sends SIGTERM and, at the same time, starts removing the pod from the endpoints that route traffic to it. That removal takes a few seconds to reach every node, and requests keep arriving meanwhile. A server that stops accepting connections right away refuses them.
With health on, app.close() (and SIGTERM, see below) runs:
app.stateturnsdraining: readiness answers 503 from now on, liveness keeps answering 200.- The server keeps serving for
shutdown.drainDelaymilliseconds. - The server stops accepting connections and waits up to
shutdown.timeoutfor requests in flight. - The
app.onClose()hooks run (the queue and scheduler drain here), then the container is disposed.
health: true changes two defaults to make this work out of the box: shutdown.drainDelay becomes 5000 and shutdown.handleSignals becomes true. Setting either explicitly wins.
export default defineConfig({
health: true,
shutdown: {
drainDelay: 5_000, // keep serving while the endpoint removal propagates
timeout: 20_000, // then wait this long for requests in flight
},
});
app.state ('starting' | 'ready' | 'draining' | 'closed') and drainDelay also work without the health routes, for a readiness route of your own. Inject AppLifecycle to read the state from a controller.
Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
strategy:
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
template:
spec:
# drainDelay (5s) + shutdown.timeout (20s) + onClose hooks, with room to spare
terminationGracePeriodSeconds: 40
containers:
- name: api
image: registry.example.com/api:1.4.0
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: production
startupProbe:
httpGet: { path: /_health/startup, port: 3000 }
periodSeconds: 2
failureThreshold: 30 # up to 60s for the onStart hooks
readinessProbe:
httpGet: { path: /_health/ready, port: 3000 }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 1
livenessProbe:
httpGet: { path: /_health/live, port: 3000 }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
Keep terminationGracePeriodSeconds above drainDelay plus shutdown.timeout plus the time your onClose hooks need, or Kubernetes kills the process with SIGKILL halfway through. Keep timeoutSeconds above the check timeout (1000 ms by default), since a check that times out still has to answer. The drain replaces the preStop: sleep hook such setups often carry; with both, the pod waits twice.
Run the server process directly (node dist/index.mjs, or exec in a shell entrypoint) so it receives SIGTERM. A wrapper such as npm start may not forward it.
Other runtimes
- Serverless (AWS Lambda, Azure Functions, Vercel): the probes work as ordinary routes, and there is nothing to drain because the platform routes requests. Readiness reports
startinguntilapp.start()has run;app.listen()calls it without binding a port on these platforms. - Nitro: set the option on the module,
vercubeNitro({ health: true }), not invercube.config.ts, because Nitro builds its route table from it. The token is read at runtime fromruntimeConfig.vercube.healthToken(NITRO_VERCUBE_HEALTH_TOKEN), so it never ends up in the build; register checks in thesetupFile. Nitro owns the server and its shutdown, so there is no drain. vercube dev: the worker restarts on every change and gets no default drain delay, so a reload stays instant.@vercube/vitein development: the Vite dev server only forwards requests to discovered controllers, so the probes are not reachable there. The production build serves them.
Options
| Option | Default | |
|---|---|---|
path | '/_health' | Base path of the three endpoints |
endpoints | live, ready, startup: a full path, or false to leave the endpoint out | |
checks | [] | Checks registered at startup |
details | 'summary' | What requests without the token see: 'none' or 'summary'. 'full' requires token |
token | Requests with Authorization: Bearer <token> get the full report | |
timeout | 1000 | Default per-check timeout in milliseconds |
cacheTtl | 1000 | Milliseconds a readiness report is reused, 0 to run the checks on every probe |
telemetry | false | Record probe requests in telemetry and devtools |