tutorial

Setting Up Uptime Monitoring for Fastify with Vigilmon

Fastify is one of the fastest Node.js web frameworks available. But raw throughput does not protect you from the failure modes that matter in production: a d...

Fastify is one of the fastest Node.js web frameworks available. But raw throughput does not protect you from the failure modes that matter in production: a database pool that exhausts under load, a plugin that throws during initialization, or a background task that quietly dies.

This tutorial covers production uptime monitoring for Fastify applications using Vigilmon. We will walk through:

  • A /health route using Fastify plugins
  • Vigilmon multi-region HTTP monitoring setup
  • Webhook alerts for DOWN/UP transitions
  • Heartbeat monitoring for background workers

Prerequisites

  • Node.js 18+
  • An existing Fastify application (fastify v4+)
  • A free account at vigilmon.online

Part 1: Add a health check route

Fastify's plugin system is the idiomatic way to register routes with their own scope. A health plugin keeps your monitoring logic isolated.

Basic health plugin

// plugins/health.js
'use strict';

const fp = require('fastify-plugin');

async function healthPlugin(fastify) {
  fastify.route({
    method: 'GET',
    url: '/health',
    schema: {
      response: {
        200: {
          type: 'object',
          properties: {
            status: { type: 'string' },
            timestamp: { type: 'string' },
            uptime: { type: 'number' },
            checks: { type: 'object' },
          },
        },
        503: {
          type: 'object',
          properties: {
            status: { type: 'string' },
            timestamp: { type: 'string' },
            uptime: { type: 'number' },
            checks: { type: 'object' },
          },
        },
      },
    },
    // Skip authentication — monitoring must reach this without credentials
    config: { skipAuth: true },
    handler: async (request, reply) => {
      const checks = {};
      let status = 'ok';

      // Database check
      if (fastify.db) {
        try {
          await fastify.db.query('SELECT 1');
          checks.database = 'ok';
        } catch (err) {
          checks.database = `error: ${err.message}`;
          status = 'degraded';
        }
      }

      // Redis check
      if (fastify.redis) {
        try {
          await fastify.redis.ping();
          checks.redis = 'ok';
        } catch (err) {
          checks.redis = `error: ${err.message}`;
          status = 'degraded';
        }
      }

      const code = status === 'ok' ? 200 : 503;

      return reply.code(code).send({
        status,
        timestamp: new Date().toISOString(),
        uptime: process.uptime(),
        checks,
      });
    },
  });
}

// fp() wraps the plugin so it shares the parent scope's decorations (db, redis, etc.)
module.exports = fp(healthPlugin, {
  name: 'health',
  fastify: '4.x',
});

Register in your main app

// app.js
'use strict';

const Fastify = require('fastify');
const healthPlugin = require('./plugins/health');
const dbPlugin = require('./plugins/db');     // your database plugin
const redisPlugin = require('./plugins/redis'); // your Redis plugin

const buildApp = async () => {
  const app = Fastify({
    logger: {
      level: process.env.LOG_LEVEL ?? 'info',
    },
  });

  // Register infrastructure first so health plugin sees the decorations
  await app.register(dbPlugin);
  await app.register(redisPlugin);

  // Register health plugin — fp() means it joins the root scope
  await app.register(healthPlugin);

  return app;
};

module.exports = buildApp;
// server.js
const buildApp = require('./app');

const start = async () => {
  const app = await buildApp();
  await app.listen({ port: process.env.PORT ?? 3000, host: '0.0.0.0' });
};

start().catch(err => {
  console.error(err);
  process.exit(1);
});

Test it:

curl http://localhost:3000/health
{
  "status": "ok",
  "timestamp": "2026-06-29T08:00:00.000Z",
  "uptime": 88.2,
  "checks": {
    "database": "ok",
    "redis": "ok"
  }
}

When a dependency is unreachable the handler returns HTTP 503. Vigilmon's HTTP monitor treats any non-2xx response as a failure and fires an alert.

TypeScript version

// plugins/health.ts
import fp from 'fastify-plugin'
import type { FastifyInstance, FastifyPluginOptions } from 'fastify'

async function healthPlugin(fastify: FastifyInstance, _opts: FastifyPluginOptions) {
  fastify.route({
    method: 'GET',
    url: '/health',
    handler: async (_request, reply) => {
      const checks: Record<string, string> = {}
      let status = 'ok'

      try {
        await (fastify as any).db.query('SELECT 1')
        checks.database = 'ok'
      } catch (err: unknown) {
        checks.database = `error: ${(err as Error).message}`
        status = 'degraded'
      }

      const code = status === 'ok' ? 200 : 503

      return reply.code(code).send({
        status,
        timestamp: new Date().toISOString(),
        uptime: process.uptime(),
        checks,
      })
    },
  })
}

export default fp(healthPlugin)

Part 2: Set up multi-region HTTP monitoring in Vigilmon

  1. Log in to vigilmon.online and click Add Monitor.
  2. Choose HTTP(S) monitor.
  3. Enter your health URL: https://yourapi.example.com/health
  4. Set the check interval. The free tier supports 5-minute intervals; paid plans support 1-minute intervals.
  5. Add an alert channel: email, Slack, or a custom webhook URL.
  6. Click Save.

Vigilmon runs checks from multiple geographic regions simultaneously. It only fires an alert after a majority of regions agree the endpoint is down — this is the key differentiator from single-location checkers, which generate false positives on regional network hiccups.

Configure response assertions

In advanced monitor settings, add a keyword check on the response body:

  • Expected keyword: "status":"ok" (exact substring match)

This catches degraded responses that still return HTTP 200. If your health handler returns {"status":"degraded"} with a 200, Vigilmon will still alert because the keyword is missing.


Part 3: Webhook alerts

// plugins/webhooks.js
'use strict';

const fp = require('fastify-plugin');

async function webhooksPlugin(fastify) {
  fastify.route({
    method: 'POST',
    url: '/webhooks/vigilmon',
    config: { skipAuth: true },
    schema: {
      body: {
        type: 'object',
        required: ['monitor_name', 'status', 'url', 'checked_at'],
        properties: {
          monitor_id: { type: 'string' },
          monitor_name: { type: 'string' },
          status: { type: 'string', enum: ['up', 'down'] },
          url: { type: 'string' },
          response_code: { type: 'integer' },
          response_time_ms: { type: 'number' },
          checked_at: { type: 'string' },
        },
      },
    },
    handler: async (request, reply) => {
      const { monitor_name, status, url, response_code, checked_at } = request.body;

      if (status === 'down') {
        fastify.log.error({ monitor_name, url, response_code, checked_at }, 'Vigilmon: DOWN');
        await notifyOnCall({ monitor_name, url, response_code });
      } else {
        fastify.log.info({ monitor_name }, 'Vigilmon: recovered');
      }

      return reply.code(204).send();
    },
  });
}

async function notifyOnCall({ monitor_name, url, response_code }) {
  const slackUrl = process.env.SLACK_WEBHOOK_URL;
  if (!slackUrl) return;

  await fetch(slackUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `*ALERT*: ${monitor_name} is DOWN\nURL: ${url}\nHTTP: ${response_code ?? 'timeout'}`,
    }),
  }).catch(err => fastify.log.warn({ err }, 'Slack notify failed'));
}

module.exports = fp(webhooksPlugin, { name: 'webhooks' });

Register it in your app:

await app.register(require('./plugins/webhooks'));

In Vigilmon, navigate to your monitor → Alert channelsAddWebhook, and enter https://yourapi.example.com/webhooks/vigilmon.

Vigilmon's webhook payload:

{
  "monitor_id": "mon_abc123",
  "monitor_name": "Fastify API /health",
  "status": "down",
  "url": "https://yourapi.example.com/health",
  "checked_at": "2026-06-29T08:01:00Z",
  "response_code": 503,
  "response_time_ms": 1301
}

Part 4: Heartbeat monitoring for background workers

Fastify is often paired with a background job system (BullMQ, Bee-Queue, etc.). HTTP monitoring will not catch a worker process that is alive but no longer processing jobs.

The heartbeat pattern closes this gap: ping Vigilmon on each successful job cycle. Silence triggers an alert.

Create the heartbeat monitor in Vigilmon

  1. In Vigilmon, click Add MonitorHeartbeat monitor.
  2. Set expected interval to match your worker's cycle (e.g., 5 minutes).
  3. Copy the unique ping URL.
  4. Set VIGILMON_HEARTBEAT_URL in your production environment.

Worker with heartbeat

// workers/emailWorker.js
'use strict';

const { Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis(process.env.REDIS_URL);
const HEARTBEAT_URL = process.env.VIGILMON_HEARTBEAT_URL;

const worker = new Worker(
  'email',
  async (job) => {
    // Process the job
    await sendEmail(job.data);
  },
  {
    connection,
    concurrency: 5,
  }
);

let lastHeartbeat = 0;
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;

worker.on('completed', async (job) => {
  // Throttle heartbeat pings — send at most once per interval
  const now = Date.now();
  if (HEARTBEAT_URL && now - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
    try {
      await fetch(HEARTBEAT_URL, { signal: AbortSignal.timeout(10_000) });
      lastHeartbeat = now;
    } catch (err) {
      console.warn('Heartbeat ping failed:', err.message);
    }
  }
});

worker.on('failed', (job, err) => {
  console.error(`Job ${job.id} failed:`, err);
  // No heartbeat ping on failure — Vigilmon alerts on silence
});

console.log('Email worker started');

Standalone heartbeat loop (without BullMQ)

// workers/scheduler.js
'use strict';

const HEARTBEAT_URL = process.env.VIGILMON_HEARTBEAT_URL;
const INTERVAL_MS = 5 * 60 * 1000;

async function pingHeartbeat() {
  if (!HEARTBEAT_URL) return;
  try {
    const res = await fetch(HEARTBEAT_URL, { signal: AbortSignal.timeout(10_000) });
    if (!res.ok) console.warn(`Heartbeat returned ${res.status}`);
  } catch (err) {
    console.warn('Heartbeat failed:', err.message);
  }
}

async function tick() {
  try {
    await runScheduledTasks();
    await pingHeartbeat(); // only on success
  } catch (err) {
    console.error('Scheduler error:', err);
  }
}

tick(); // run immediately on start
setInterval(tick, INTERVAL_MS);

Part 5: Fastify-specific tips

Use fastify-healthcheck for simple cases

If you only need a basic liveness probe without dependency checks:

npm install fastify-healthcheck
await app.register(require('fastify-healthcheck'));
// Registers GET /health automatically

This works for Kubernetes liveness probes but does not check your dependencies. The custom plugin from Part 1 is better for Vigilmon because it returns HTTP 503 on dependency failure, which triggers an alert.

Structured logging integrates with Vigilmon response time

Fastify's built-in pino logger emits structured JSON. Log response time in your health handler so you can correlate Vigilmon alert timestamps with your log aggregator:

handler: async (request, reply) => {
  const start = Date.now();
  // ... dependency checks ...
  const durationMs = Date.now() - start;

  fastify.log.info({ durationMs, status }, 'health check');

  return reply.code(code).send({ status, durationMs, checks });
}

Graceful shutdown

Fastify's fastify.close() hooks let you drain active connections before the process exits. Add a SIGTERM handler so your health route returns 503 during rolling deploys, which prevents Vigilmon from alerting on intentional restarts:

let shuttingDown = false;

process.on('SIGTERM', async () => {
  shuttingDown = true;
  await app.close();
  process.exit(0);
});

// In health handler
if (shuttingDown) {
  return reply.code(503).send({ status: 'shutting_down' });
}

Summary

Your Fastify application now has production-grade monitoring across multiple layers:

  1. Health plugin — checks real dependencies with Fastify's plugin scoping; returns HTTP 503 so Vigilmon alerts automatically.
  2. Webhook plugin — receives Vigilmon DOWN/UP events with Fastify's JSON schema validation and routes them to Slack or any on-call tool.
  3. Worker heartbeat — pings Vigilmon after each successful job cycle; silence triggers an alert before users notice.
  4. Multi-region consensus — Vigilmon requires agreement across regions before alerting, eliminating single-node false positives.
  5. Graceful shutdown — health returns 503 during deploys so you do not get alert noise on intentional restarts.

Vigilmon's free tier covers up to 5 monitors with 5-minute check intervals — enough for your API, a worker, and a cron from day one.


Monitor your Fastify app free at vigilmon.online

#fastify #javascript #monitoring #devops

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →