Health Check
Two endpoints with two audiences. GET /health is the minimal liveness answer a load balancer or a Kubernetes probe polls - status and the server clock, nothing more. GET /health/stats is the diagnostic one an operator opens during an incident - build stamp, process and memory - and it is closed on every host that cannot prove it is a development machine.
Quick Reference
| Item | Value |
|---|---|
| Package | @venizia/ignis |
| Class | HealthCheckComponent |
| Controller | HealthCheckController |
| Reporter | HealthCheckReporter - assembles both answers, reusable from your own routes |
| Runtimes | Both |
Import Paths
import { HealthCheckComponent, HealthCheckBindingKeys, HealthCheckHeaders } from '@venizia/ignis';
import type { IHealthCheckOptions } from '@venizia/ignis';
import { BuildInfoRegistry } from '@venizia/ignis-helpers/core';In one example
Register the component with no configuration - GET /health and POST /health/ping are live immediately, and GET /health/stats is live when NODE_ENV names a development environment.
import { HealthCheckComponent, BaseApplication, ValueOrPromise } from '@venizia/ignis';
export class Application extends BaseApplication {
preConfigure(): ValueOrPromise<void> {
this.component(HealthCheckComponent);
}
}curl localhost:3000/health
# { "status": "ok", "timestamp": "2026-09-11T07:30:00.000Z" }How it works
GET /healthdiscloses nothing.statusandtimestamponly. The clock is the one addition that costs nothing to disclose while catching the failure behind every JWT and HMAC mystery: a server whose time has drifted.GET /health/statsis gated twice. First bystats.enable, decided when the controller is bound - a disabled route is unmounted, not empty, so it is absent from the OpenAPI document too. Second bystats.secretKey, checked per request against theX-Health-Keyheader.- The default is closed. With no
enableset, the route is mounted only whenNODE_ENVis one ofEnvironment.DEVELOPMENT_ENVS(local,debug,development,dev,sit).staging,uat,production, an unrecognised name and an unsetNODE_ENVall keep it shut - the same fail-closed ruleAppErrorMiddlewareuses to decide whether a stack trace may leave the server. - A refusal is a 404, and byte-identical to an unmounted route. A wrong or missing key runs the application's own notFound handler rather than throwing, so the answer cannot confirm the route exists. A
secretKeythat is set but blank fails closed too. - The build stamp is pushed in, never read by the reporter. The reporter never opens a file and never spawns
git. It readsstats.buildInfofirst, thenBuildInfoRegistry(filled at your entrypoint byBuildInfoStamp.register()), then the application's owngetAppInfo(). A host that registered no stamp still reports its real name and version; anything unknowable readsunspecified. - The gate is per application, not per process. The controller receives its options and the application info through
@inject, so two applications in one process - IGNIS nests them - each keep their own gate. - Auto-registered controller.
HealthCheckComponent.binding()applies@controller({ path })viaReflect.decorateat runtime, then callsthis.application.controller(HealthCheckController). The path comes from the options binding, not a hardcoded class decorator. - Your options always win, whatever the order. The constructor only STORES a default
Bindingobject.initDefaultBindings()applies it from insideconfigure(), which the boot sweep runs at theregisterComponentsstep - four steps afterpreConfigure()returns. So athis.bind()anywhere inpreConfigure()takes the key first, and the default finds it taken and steps aside. Options passed asthis.component(HealthCheckComponent, { options })win over both:configure()binds them beforeinitDefaultBindings()runs. - A mounted, unguarded stats route warns at boot.
stats.enable: truebypasses the environment gate, so the code that opened the route locally opens it in production too. WhenHealthCheckReporter.isStatsUnguarded()says yes,binding()logs one warning naming the path and the ambient environment. It warns rather than refuses - only the operator knows whether the port is reachable.
Common tasks
Open the stats route in production behind a key
this.component(HealthCheckComponent, {
options: {
restOptions: { path: '/health' },
stats: { enable: true, secretKey: process.env.APP_HEALTH_SECRET_KEY },
},
});curl -H 'X-Health-Key: ...' localhost:3000/health/stats{
"status": "ok",
"timestamp": "2026-09-11T07:30:00.000Z",
"build": {
"service": "@nx/sale", "version": "1.4.2",
"commit": "8f80b2a90d12", "branch": "develop",
"builtAt": "2026-09-10T15:00:00.000Z"
},
"process": {
"pid": 42, "uptime": 3600.5, "uptimeHuman": "1h 0m 0s",
"runtime": "bun 1.4.2", "environment": "production"
},
"memory": { "rss": "64.2 MB", "heapUsed": "32.1 MB", "heapTotal": "48.0 MB" }
}Without secretKey the route is open to whatever can reach the port. On a cluster-internal port that is often the right call; on a public one it is not.
Stamp the build so stats.build names a real commit
// .configs/builders/compile.ts - the compile bakes the stamp in
import { BunCompiler } from '@venizia/dev-configs/compilers';
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';
await BunCompiler.compile({ entrypoint: './src/index.ts', define: await BuildInfoStamp.define() });// src/index.ts - one line at the entrypoint
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';
await BuildInfoStamp.register();A compiled binary reports the stamp it was compiled with. A run from source resolves it from git at start. The resolver reads APP_ENV_BUILD_VERSION / APP_ENV_BUILD_COMMIT_TAG / APP_ENV_BUILD_DATE first, then the generic APP_BUILD_* and provider-specific variables, then git, then package.json. See Stamping a build.
Customize the health check path
Pass the options at the call site:
this.component(HealthCheckComponent, { options: { restOptions: { path: '/health-check' } } });Or bind them yourself, anywhere in preConfigure() - before or after this.component():
import { HealthCheckBindingKeys, IHealthCheckOptions } from '@venizia/ignis';
this.bind<IHealthCheckOptions>({
key: HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS,
}).toValue({ restOptions: { path: '/health-check' } });
this.component(HealthCheckComponent);Call the ping endpoint
POST /health/ping echoes message back with a server timestamp - useful for round-trip latency checks and body-parsing smoke tests.
curl -X POST localhost:3000/health/ping \
-H 'content-type: application/json' \
-d '{"message":"hello"}'
# { "type": "PONG", "date": "2026-02-11T12:00:00.000Z", "message": "hello" }| Field | Type | Required | Constraints |
|---|---|---|---|
type | string | No | Defaults to "PING" |
message | string | Yes | Min 1, max 255 characters |
Reference
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
restOptions.path | string (optional) | '/health' | Base path for every health endpoint |
stats.enable | boolean (optional) | open only in Environment.DEVELOPMENT_ENVS | Mounts GET <path>/stats. false unmounts it. A non-boolean (a config-derived "false") is ignored and the environment default applies |
stats.secretKey | string (optional) | none - route open | Required in X-Health-Key. A blank value fails closed |
stats.buildInfo | IBuildInfo (optional) | BuildInfoRegistry.get() | Wins over the registry; a host that stamps its own build passes it here |
interface IHealthCheckOptions {
restOptions?: { path?: string };
stats?: {
enable?: boolean;
secretKey?: string;
buildInfo?: IBuildInfo;
};
}Binding keys
| Key | Constant | Type | Required | Default |
|---|---|---|---|---|
@app/health-check/options | HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS | IHealthCheckOptions | No | { restOptions: { path: '/health' } } |
@app/health-check/application-info | HealthCheckBindingKeys.APPLICATION_INFO | IApplicationInfo | No - bound by the component | the application's getAppInfo() |
Headers
| Constant | Value | Used by |
|---|---|---|
HealthCheckHeaders.SECRET_KEY | x-health-key | GET /health/stats when stats.secretKey is set |
The stats route declares this header in its OpenAPI definition, so the API reference renders an input for it. It stays optional there: a host with no stats.secretKey needs no key.
REST paths
| Constant | Value | Full path (default) |
|---|---|---|
HealthCheckRestPaths.ROOT | / | GET /health |
HealthCheckRestPaths.STATS | /stats | GET /health/stats |
HealthCheckRestPaths.PING | /ping | POST /health/ping |
Paths are relative to the base path configured in IHealthCheckOptions.restOptions.path.
API endpoints
| Method | Path | Description | Response |
|---|---|---|---|
GET | /health | Liveness | { "status": "ok", "timestamp": "..." } |
GET | /health/stats | Build stamp, process, memory - gated | see above; 404 when unmounted or refused |
POST | /health/ping | Echo test | { "type": "PONG", "date": "...", "message": "..." } |
The reporter
HealthCheckReporter is exported so a host with its own health route can answer the same shapes:
| Method | Returns |
|---|---|
buildSummary() | { status, timestamp } |
buildStats({ options, appInfo }) | the full /health/stats body |
resolveBuildInfo({ options, appInfo }) | the resolved build block, every field a string |
isStatsEnabled({ options }) | the mount decision |
isStatsUnguarded({ options }) | whether the route is mounted, keyless, and off a development host. The component turns a true into one boot warning |
isStatsAuthorized({ options, header }) | the per-request decision |
isStatsEnabled and isStatsUnguarded each take an optional environment: () => string \| undefined so a test can supply the ambient environment without writing process.env.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
GET /health returns 404 | HealthCheckComponent not registered, or registered after controllers were already mounted | Call this.component(HealthCheckComponent) in preConfigure() |
GET /health/stats returns 404 on staging | The default is closed outside the development environments | Set stats: { enable: true, secretKey } explicitly |
GET /health/stats returns 404 with the right key | secretKey is blank, or arrived as a non-string from config | Export the variable; the gate fails closed on a blank or wrong-typed key |
build.commit reads unspecified | No stamp was registered and getAppInfo() carries no commit | Pass define: await BuildInfoStamp.define() in the compile and call BuildInfoStamp.register() at the entrypoint |
| Custom path not applied | The options were bound outside preConfigure(), so they landed after configure() had already read the key | Bind inside preConfigure(), or pass this.component(HealthCheckComponent, { options }) |
POST /health/ping returns a validation error | message is missing, or exceeds 255 characters | Send a message string between 1 and 255 characters |
See also
Guides:
- Components Overview - Component system basics
- Application - Registering components
Components:
- All Components - Built-in components list
Changelog:
Best Practices:
- Deployment Strategies - Production monitoring
Files:
packages/core-server/src/components/health-check/component.ts-HealthCheckComponentpackages/core-server/src/components/health-check/controller.ts-HealthCheckControllerpackages/core-server/src/components/health-check/reporter.ts-HealthCheckReporterpackages/core-server/src/components/health-check/common/types.ts-IHealthCheckOptionspackages/core-server/src/components/health-check/common/keys.ts-HealthCheckBindingKeyspackages/core-server/src/components/health-check/common/constants.ts-HealthCheckHeaderspackages/core-server/src/components/health-check/common/rest-paths.ts-HealthCheckRestPathspackages/helpers/src/utilities/build-info.utility.ts-BuildInfoRegistry