Percio Castelo.
PT Resume
Cloudflare Health Checks: Origin Monitoring and Edge Probes
← Back to blog

Cloudflare Health Checks: Origin Monitoring and Edge Probes

Updated 9/25/2026 · Published 9/24/2026 · 8 min · DevOps · 569 views

I have lost sleep over web services crashing silently behind a reverse proxy, only finding out half an hour later when a user emailed me to complain. That is the main drawback of running behind Cloudflare: from the outside, DNS resolves fast and the edge network serves a clean error page with Error 521 (Web Server is Down) or Error 522 (Connection Timed Out). The visitor sees the downtime, but your server never receives a single request.

To avoid relying on user reports, I run automated probes. Cloudflare offers a native feature called Health Checks. In this guide, I cover how it works, what the plan limits look like, and how I built a free alternative using Cloudflare Workers and a static Nginx endpoint.


How Cloudflare Health Checks work#

Unlike running an uptime script on the same VPS or pinging from a single external box, Cloudflare Health Checks run from edge datacenters across the globe. They send scheduled requests directly to your origin IP or hostname.

The main benefits:

  1. Individual origin monitoring: you do not need the Load Balancing add-on. You can point probes at a single standalone VPS or dedicated server.
  2. Granular parameters: specify protocol (HTTP, HTTPS, or TCP), port, exact URI path (such as /healthz), HTTP method, and expected status codes (200).
  3. Regional latency metrics: the Health Check Analytics dashboard breaks down latency by region, showing connectivity bottlenecks before total failure.
  4. Clear failure reasons: when an origin fails, logs specify whether it was a connection timeout, connection refused, TLS handshake error, or unexpected HTTP code.

Plan tier limits: Standalone vs Passive Monitoring#

When opening the Cloudflare dashboard to set up an active probe, you run into plan restrictions:

FeatureFree PlanPro PlanBusiness / Enterprise
Standalone Health Checks0 included10 included50 to 1,000 included
Minimum intervalN/A60 seconds10 to 15 seconds
Passive Origin MonitoringYesYesYes
Email notificationsYesYesYes

Active, standalone Health Checks require at least a Pro plan ($20 to $25 per month). The Free plan includes zero standalone active checks.

How to configure notifications in the dashboard#

To receive alerts as soon as Cloudflare detects any origin status change:

  1. In the global account sidebar (under Manage Account), go to Notifications.
  2. Click the Add button.
  3. In the Product filter dropdown, select Health Checks (Verificações de integridade).
  4. In the table, click on Health Checks status notification (Notificação de status das verificações de integridade).
  5. Name your alert, choose the target domain/health check, pick the trigger event (such as when status changes to Unhealthy), and add your notification email or webhook.

Setting up a lightweight health check endpoint (/healthz)#

A common mistake in uptime monitoring is pointing probes to the root path (/) or to a heavy application route (like WordPress index.php). Hitting those routes every 60 seconds forces the server to boot runtimes, query databases, and waste CPU cycles.

To avoid this, I set up a static response straight at the web server layer. Here is how to configure it in Nginx, Plesk, and Apache:

1. Standalone Nginx#

Add this location block inside your site configuration (inside the server block):

# Ultra-lightweight static health check (no PHP or database load)
location = /healthz {
    access_log off;
    default_type text/plain;
    return 200 "OK\n";
}

This setup provides two advantages:

  • access_log off;: Nginx skips writing these hits to disk, preventing access logs from growing by tens of thousands of lines each month.
  • return 200 "OK\n";: Nginx serves the response in less than a millisecond straight from memory, never touching PHP-FPM.

Test the syntax and reload:

nginx -t && systemctl reload nginx

2. Plesk Obsidian (Nginx + Apache)#

In most Plesk installations, Nginx runs in front of Apache as a reverse proxy. To handle health check probes directly at the Nginx edge without waking up Apache or PHP:

  1. Inside your Plesk dashboard, open your domain and navigate to Apache & NGINX Settings.
  2. Scroll down to Additional NGINX directives.
  3. Paste the static directive:
location = /healthz {
    access_log off;
    default_type text/plain;
    return 200 "OK\n";
}
  1. Click Apply or OK. Plesk's Nginx will now handle /healthz directly with near-zero latency.

If you are on a restricted shared account in Plesk without permission to edit Nginx directives, simply create a static file named healthz inside your httpdocs/ directory containing the text OK with read permissions (644).

3. Apache and cPanel (.htaccess)#

If your server runs standalone Apache, you can handle the endpoint either purely through .htaccess or via a static file:

Option A: Return 200 directly via .htaccess (no physical file needed)

Using mod_rewrite and ErrorDocument:

# Direct 200 response without hitting index.php or CMS
RewriteEngine On
RewriteRule ^healthz$ - [R=200,L]
ErrorDocument 200 "OK"

Option B: Dedicated static file in DocumentRoot

Create a plain text file in your public root (public_html/healthz or /var/www/html/healthz):

echo "OK" > /var/www/html/healthz

And in your .htaccess file, prevent CMS rewrite rules from intercepting it:

# Ensure healthz serves as plain text and skips CMS rewrites
<Files "healthz">
    ForceType text/plain
    Header set Cache-Control "no-cache, no-store, must-revalidate"
</Files>

RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]

The free alternative: Worker with 60s Cron Trigger#

If you are on the Free tier and need active health checks every 60 seconds without waiting for real visitors, the cleanest and most resilient setup I created in my laboratory is a Cloudflare Worker powered by Cron Triggers.

The Worker runs on the Cloudflare global edge, executes a fast HTTP probe against /healthz, and rather than spamming alerts every single minute or hitting quota ceilings, tracks origin state using Cloudflare KV.

1. Prerequisites#

  • A free Cloudflare account at dash.cloudflare.com.
  • Node.js installed (node --version).
  • Wrangler CLI installed (npm install -g wrangler or via npx wrangler) and authenticated (wrangler login).
  • Your web server running /healthz returning HTTP 200 (either over a public IP or via a Cloudflare Tunnel).

2. Initializing the project#

In the terminal, I scaffold a new Worker project:

wrangler init healthz-monitor
cd healthz-monitor

Wrangler generates wrangler.toml, src/index.js, and .gitignore.

3. Scheduling in wrangler.toml#

I edit wrangler.toml to declare the worker name, compatibility date, observability, and cron schedule:

name = "healthz-monitor"
main = "src/index.js"
compatibility_date = "2026-09-25"

[observability]
enabled = true

[triggers]
crons = ["* * * * *"]   # Runs every 60 seconds

4. Stateful edge probe implementation (src/index.js)#

Here is the complete implementation I use in production. It solves three critical real-world monitoring problems:

  1. Eliminates alert fatigue: It only sends notifications upon state transitions (ok -> down and down -> ok), preventing hundreds of repeated messages while an outage is being investigated.
  2. Stays within KV Free limits: Cloudflare KV Free limits writes to 1,000 per day. Writing every minute would total 1,440 daily writes, exceeding the free quota. By writing only when origin status changes, daily write operations stay close to zero.
  3. Hardened Read-Only Telemetry (SRE/SecOps Best Practice): The HTTP fetch handler is strictly read-only. It exposes cached health telemetry via JSON for external status monitors, but prevents any unauthenticated public web request from causing side-effects (such as forcing probes outside the cron schedule or triggering alerts).

You can inspect the complete implementation below or access the canonical repository directly on GitHub Gist:

healthz-monitor — Cloudflare Worker: Stateful Edge Health Probe
GistExpand code
/**
 * Cloudflare Worker: Healthz Monitor with Stateful Edge Probing
 */

const HEALTHZ_URL = 'https://perciocastelo.com.br/healthz';
// const HEALTHZ_URL = 'http://127.0.0.1:8080/healthz'; // If routed behind a Cloudflare Tunnel

export default {
  // Scheduled handler: executed automatically by cron every 60s
  async scheduled(event, env, ctx) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 5000); // 5-second probe timeout

    let ok = false;
    let statusCode = 0;
    try {
      const res = await fetch(HEALTHZ_URL, {
        signal: controller.signal,
        headers: {
          'User-Agent': 'Cloudflare-Worker-Healthz-Monitor/1.0 (+https://perciocastelo.com.br)'
        }
      });
      statusCode = res.status;
      ok = res.status === 200;
    } catch (err) {
      ok = false;
      statusCode = err.name === 'AbortError' ? 408 : 503;
    } finally {
      clearTimeout(timeout);
    }

    if (!env.HEALTH_STATE) {
      console.warn('KV Namespace HEALTH_STATE not configured in wrangler.toml.');
      return;
    }

    // Only write to KV when state TRANSITIONS (ok <-> down)
    // Keeps write counts negligible, avoiding the 1,000 writes/day KV Free ceiling
    const prev = await env.HEALTH_STATE.get('status');
    const cur = ok ? 'ok' : 'down';

    if (prev !== cur) {
      await env.HEALTH_STATE.put('status', cur);
      await env.HEALTH_STATE.put('last_change', new Date().toISOString());
      await env.HEALTH_STATE.put('last_status_code', String(statusCode));

      // Dispatch alerts only on state changes to prevent notification spam
      if (!ok && prev !== 'down') {
        const msg = `🚨 [OUTAGE ALERT] Origin ${HEALTHZ_URL} is unreachable! HTTP Status: ${statusCode}`;
        ctx.waitUntil(sendAlert(env, msg));
      } else if (ok && prev === 'down') {
        const msg = `✅ [RECOVERED] Origin ${HEALTHZ_URL} is back online and responding normally (HTTP 200).`;
        ctx.waitUntil(sendAlert(env, msg));
      }
    }
  },

  // HTTP handler: strictly read-only status telemetry
  // SRE/SecOps pattern: No unauthenticated public requests may produce side-effects
  // (triggering probes out-of-schedule or dispatching alert webhooks).
  async fetch(request, env) {
    let currentStatus = 'unknown';
    let lastChange = null;
    let lastStatusCode = null;

    if (env.HEALTH_STATE) {
      currentStatus = (await env.HEALTH_STATE.get('status')) || 'unknown';
      lastChange = await env.HEALTH_STATE.get('last_change');
      lastStatusCode = await env.HEALTH_STATE.get('last_status_code');
    }

    return new Response(
      JSON.stringify(
        {
          monitor: 'healthz-monitor',
          target: HEALTHZ_URL,
          current_status: currentStatus,
          last_status_code: lastStatusCode,
          last_change: lastChange,
          timestamp: new Date().toISOString()
        },
        null,
        2
      ),
      {
        headers: {
          'content-type': 'application/json; charset=utf-8',
          'cache-control': 'public, max-age=15, stale-while-revalidate=45',
          'x-content-type-options': 'nosniff'
        }
      }
    );
  }
};

async function sendAlert(env, message) {
  const discordWebhook = env.DISCORD_WEBHOOK;
  const telegramToken = env.TELEGRAM_BOT_TOKEN;
  const telegramChatId = env.TELEGRAM_CHAT_ID;

  const tasks = [];

  // Discord notification
  if (discordWebhook) {
    tasks.push(
      fetch(discordWebhook, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ content: message })
      }).catch(err => console.error('Error dispatching Discord webhook:', err))
    );
  }

  // Telegram notification
  if (telegramToken && telegramChatId) {
    tasks.push(
      fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          chat_id: telegramChatId,
          text: message
        })
      }).catch(err => console.error('Error dispatching Telegram message:', err))
    );
  }

  if (tasks.length > 0) {
    await Promise.allSettled(tasks);
  }
}

5. Creating KV namespace and secrets#

Provision the KV namespace on Cloudflare to hold health states:

npx wrangler kv namespace create HEALTH_STATE

The command returns a unique identifier (id), which is also displayed in the KV Namespaces list in your dashboard:

KV namespace HEALTH_STATE provisioned in Cloudflare dashboard

Add the generated namespace ID to the bottom of wrangler.toml:

[[kv_namespaces]]
binding = "HEALTH_STATE"
id = "YOUR_GENERATED_ID"

To avoid committing sensitive webhook URLs or bot tokens, store them as encrypted secrets using wrangler secret put. Note that Wrangler expects the variable name as an argument and prompts for the value securely:

# For Discord alerts:
echo "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" | npx wrangler secret put DISCORD_WEBHOOK

# Or for Telegram alerts:
npx wrangler secret put TELEGRAM_BOT_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID

In the Cloudflare dashboard under Worker Settings, the KV namespace association and log observability persistence are confirmed active:

KV namespace binding and observability settings in Cloudflare Dashboard

6. Testing locally and on-demand endpoints#

To run and debug the Worker locally:

npx wrangler dev

Because local dev does not fire cron triggers automatically, test your worker in two practical ways:

  1. Simulate cron via terminal:
   curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"
  1. Inspect status via browser:

Open http://localhost:8787/ to view the JSON telemetry reflecting KV-persisted state.

Upon the initial transition or detection cycle, the webhook delivers the formatted test alert directly into the Discord channel:

Live test alert received in Discord channel via Webhook

7. Deploying to production#

Once verified, deploy to the Cloudflare network:

npx wrangler deploy

Your edge probe is now active, executing every minute without human intervention across Cloudflare's global edge network. You can monitor the real-time status at any time by visiting your worker URL (e.g. https://healthz-monitor.your-subdomain.workers.dev).

In the Workers & Pages dashboard, healthz-monitor appears listed and running:

healthz-monitor Worker active in Cloudflare Workers and Pages dashboard

Opening the worker overview reveals the complete edge probe topology: the public status route, active log observability, the 60-second cron schedule, and the KV namespace binding:

Topology overview and active cron trigger for healthz-monitor


Free Plan Quotas vs Real Usage (2026)#

Official Cloudflare quotas confirm how generous this free serverless setup is:

MetricFree Plan QuotaMy Usage (1 probe/min)Status
Requests per day100,000 / day~1,440 / day (~43,200 / month)✅ ~1.4% of quota
Cron Triggers per account5 cron triggers1 used✅ Ample headroom
CPU Time per request10 ms (or 50 ms)< 1 ms (simple lightweight fetch)✅ Minimal
KV Writes per day1,000 / day~0 to 20 (transition changes only)✅ Zero risk of limit breach
Subrequests per request50 / request1 to 2 subrequests✅ Minimal

Pro plan setup (Native Dashboard)#

For zones running on Cloudflare Pro or Business:

  1. In the zone dashboard, open Traffic and click Health Checks.
  2. Click Create Health Check.
  3. Configure the probe:
  • Name: Origin-Healthz
  • Path: /healthz
  • Protocol: HTTPS
  • Port: 443
  • Expected response codes: 200
  • Interval: 60 seconds
  • Retries: 2 (avoids false alerts from transient blips)
  • Timeout: 5 seconds
  1. Save and proceed to the notifications step to attach your email or webhook alerts.

Wrap up#

You do not need to spend $20 a month just to know when your server goes down. Cloudflare's passive monitoring handles live traffic for free, and a lightweight Worker with a Cron Trigger gives you active, 60-second checks with instant notifications straight to your phone.

Was this article helpful?

Leave a quick reaction to help prioritize future technical guides:

569 reads
Creative Commons Attribution-NonCommercial

This post is licensed under CC BY-NC.

Comments

Join the discussion below.

0 comments