Health Checks

Liveness, readiness and startup probes, and zero-downtime rolling deploys

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:

vercube.config.ts
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

Endpoint200 when503 when
/_health/livethe process serves requestsnever; nothing is checked
/_health/readystarted, not shutting down, every critical check passesstarting, draining, or a critical check fails
/_health/startupthe app.onStart() hooks have finishedthey 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:

vercube.config.ts
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:

CheckRegistered byWhat it does
queue:<mount>QueuePlugin, for BullMQ, RabbitMQ and Kafka mountsa Redis read, opening a channel, describeCluster
scheduler:storeSchedulerPlugin, for the Redis and Postgres storesPING, SELECT 1
storage:<mount>StorageManager, for S3 mountsHeadObject, 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:

vercube.config.ts
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:

vercube.config.ts
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:

vercube.config.ts
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:

  1. app.state turns draining: readiness answers 503 from now on, liveness keeps answering 200.
  2. The server keeps serving for shutdown.drainDelay milliseconds.
  3. The server stops accepting connections and waits up to shutdown.timeout for requests in flight.
  4. 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.

vercube.config.ts
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 starting until app.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 in vercube.config.ts, because Nitro builds its route table from it. The token is read at runtime from runtimeConfig.vercube.healthToken (NITRO_VERCUBE_HEALTH_TOKEN), so it never ends up in the build; register checks in the setupFile. 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/vite in development: the Vite dev server only forwards requests to discovered controllers, so the probes are not reachable there. The production build serves them.

Options

OptionDefault
path'/_health'Base path of the three endpoints
endpointslive, 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
tokenRequests with Authorization: Bearer <token> get the full report
timeout1000Default per-check timeout in milliseconds
cacheTtl1000Milliseconds a readiness report is reused, 0 to run the checks on every probe
telemetryfalseRecord probe requests in telemetry and devtools