Self-Hosted Cron Monitoring: Healthchecks, Uptime Kuma, or DIY
Contents
Self-hosted cron monitoring means running the dead man's switch yourself, and this guide compares four ways to do it: Healthchecks, an open-source cron monitor; Uptime Kuma's push monitors; Prometheus with Alertmanager; or a checker script you write. Healthchecks reads cron expressions in a time zone you choose, Uptime Kuma expects a push every fixed number of seconds, and Prometheus suits teams that already run it. Whichever you pick, you also own the monitor's uptime, its alert delivery, upgrades, backups, and the hardest requirement: it must fail separately from the jobs it watches. Versions checked October 25, 2026: Healthchecks 5.0.1, Uptime Kuma 2.5.7, Prometheus 3.15, Alertmanager 0.34.
Four Ways to Self-Host, Compared#
| Healthchecks 5.0.1 | Uptime Kuma 2.5.7, Push type | Prometheus and Alertmanager | Homegrown checker | |
|---|---|---|---|---|
| License | BSD 3-Clause | MIT | Apache 2.0 | Yours |
| You run | Django app, database, sendalerts, SMTP relay, TLS proxy |
One Node.js app with SQLite or MariaDB | Prometheus, Alertmanager, node_exporter or Pushgateway | A table, a checker, a mail path |
| Expected timing | Period, cron expression with time zone, or OnCalendar | Seconds since the last push | A fixed age per rule | A fixed age per row |
| Signals from a job | Success, start, fail, exit status, log | Up or down, a message, an optional number | Gauges the job writes | A success timestamp |
| A hung run | Down when no success follows a start within grace | Seen only as a missing push | Seen as a missed run | Seen as a missed run |
| A job that never ran | Check stays New, with no alert | Down after the first interval | Needs an absent() rule |
Overdue if its row starts empty |
| Alert routes | 25+ integrations | 90+ notification services | Email, Slack, PagerDuty, webhooks, and more | Whatever you script |
Sources are linked in the sections below; Prometheus and Alertmanager licenses are in their repositories (Prometheus, Alertmanager).
Healthchecks: An Open-Source Cron Monitor You Run Yourself#
Healthchecks is licensed under the BSD 3-Clause license and is also available as a hosted service at healthchecks.io (README). Version 5.0.1 shipped October 22, 2026 (release). Its building blocks are Python 3.12+, Django 6.1, and PostgreSQL, MySQL, or MariaDB.
What You Run#
- The web app, which receives pings and serves the dashboard and API. Since 5.0, the Docker image runs it under gunicorn instead of uWSGI.
sendalerts, a long-running process that polls the database for checks changing state. The self-hosting guide is blunt: "Whensendalertsis not running, the Healthchecks instance will not send out any alerts" (self-hosted guide).sendreportssends reports and reminders;smtpdis optional, for pinging by email.- An SMTP relay for login links and email alerts, configured with your credentials.
- A TLS-terminating reverse proxy. The image doesn't terminate TLS, and the proxy must set
X-Forwarded-For, or clients can "circumvent, among other things, the IP-based rate limiting in the login form" (Docker guide). - Backups and cleanup. By default it keeps the 100 most recent pings per check, and the guide asks for its cleanup commands to run regularly along with "regular, automatic database backups."
The pre-built images run migrations, sendalerts, sendreports, and smtpd in one container, and since 5.0 a crash in any of those processes stops the whole container, so a restart policy restarts them together (changelog).
Schedules and Signals#
A check uses a Simple schedule (period plus grace time), a Cron schedule, or an OnCalendar schedule for systemd timers (configuring checks). A Cron schedule takes the crontab expression, the server's time zone, and a grace time. Healthchecks computes the next expected ping from the last one in that zone with its cronsim library (models.py), so weekday-only jobs and servers on local time need no workaround. A check that has never been pinged stays New and doesn't go down (get_status).
Jobs send success, start, fail, log, or exit-status signals over HTTP, to UUID or slug URLs (Pinging API), or by email when smtpd runs. After a start signal, the grace time is also "the maximum allowed time gap between 'start' and 'success' signals," so a hung run goes down on its own. Reporting a cron job's exit status takes one request:
#!/bin/sh
# /opt/backup-hc.sh: run the backup, report its exit status to a self-hosted Healthchecks check.
/opt/backup.sh
rc=$?
curl --fail --silent --show-error --max-time 5 --output /dev/null \
"https://hc.internal.example.net/ping/$HC_PING_UUID/$rc" || true
exit "$rc"Healthchecks treats 0 as success and any other status as failure. The URL starts with your instance's PING_ENDPOINT, by default SITE_ROOT plus /ping/.
Integrations Need Your Own Accounts#
The README lists 25+ notification integrations. On your own instance, several need accounts or services you set up (server configuration): SMS, phone calls, and WhatsApp need a Twilio account; Signal needs a signal-cli daemon; Discord needs an application you register. Without your own PagerDuty app, users paste integration keys by hand. Integrations other than Apprise can't reach private IP addresses unless you set INTEGRATIONS_ALLOW_PRIVATE_IPS, which matters when your webhook or chat server lives on an internal network.
Uptime Kuma: Push Monitors on a Fixed Interval#
Uptime Kuma is MIT licensed and calls itself "an easy-to-use self-hosted monitoring tool." Most of its monitor types probe HTTP, TCP, DNS, and similar targets, the uptime side of monitoring; Push is for jobs (README). Version 2.5.7 shipped October 23, 2026 (release). It runs as one Node.js application; the README's Docker setup uses the louislam/uptime-kuma:2 image with data in /app/data on a local volume (the README says NFS isn't supported), stored in SQLite, an embedded MariaDB, or an external MariaDB (database.js).
How a Push Monitor Decides a Job Is Down#
A push monitor's URL ends in /api/push/ and a token, its only credential, and takes optional status, msg, and ping parameters (api-router.js). The dashboard tells you to call it every Heartbeat Interval seconds. In 2.5.7's source (monitor.js):
- When the interval, plus a one-second buffer, passes since the last push, the check fails with "No heartbeat in the time window."
- With Retries at 0, the default, the monitor goes down and notifies at once. With Retries above 0, it goes pending, rechecks every Heartbeat Retry Interval, and goes down after the last retry (retry logic). The alert comes about one interval plus retries times the retry interval after the last push.
- A push with any
statusother thanupreports down. Retries apply to it too, so with Retries set, an explicit failure goes pending first (determineStatus). - Notifications go out on the change to down and on recovery; "Resend every N times" repeats them while the monitor stays down.
- A new monitor that never receives a push goes down after its first interval and any retries, so a job that never runs is caught.
It Counts Seconds, Not Cron Schedules#
The push monitor stores an interval, not a cron expression. In 2.5.7, the only cron setting is a maintenance window's schedule (maintenance.js). For cron jobs that means:
- The deadline is relative to the last push. With an 86,400-second interval, today's completion push must land within 24 hours of yesterday's, so the interval must absorb run-time variation.
- Irregular schedules need the longest gap. A weekdays-only job needs an interval over 72 hours to survive the weekend, and a missed Tuesday run then alerts on Thursday.
- There's no start signal. The only duration is a
pingnumber the job sends itself, and a hung run looks like a missed push.
A wrapper that reports the job's result:
#!/bin/sh
# /opt/backup-kuma.sh: run the backup, report its result to an Uptime Kuma push monitor.
# KUMA_PUSH_URL is the monitor's push URL without the query string the dashboard shows.
/opt/backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then status=up; else status=down; fi
curl --fail --silent --show-error --max-time 5 --output /dev/null --get \
--data-urlencode "status=$status" \
--data-urlencode "msg=backup.sh exited with code $rc" \
"$KUMA_PUSH_URL" || true
exit "$rc"Strip the ?status=up&msg=OK&ping= the dashboard appends. Otherwise the request carries status twice, which Uptime Kuma 2.5.7 records as down.
Prometheus and Alertmanager: Already Self-Hosted#
With Prometheus in place, each job writes a last-success timestamp through node_exporter's textfile collector or the Pushgateway, and a rule alerts on its age. Monitor cron jobs with Prometheus has the wrappers, rules, and limits. In short, Prometheus never learns the cron schedule, so every threshold is a fixed age, and a job whose series vanishes needs its own absent() rule. Push vs pull monitoring explains why a batch job needs that holder in the first place.
Prometheus's documentation also covers watching the monitor: it asks for alerts that its servers, Alertmanagers, and Pushgateways "are available and running correctly," prefers "a blackbox test that alerts are getting from PushGateway to Prometheus to Alertmanager to email," and adds external black-box monitoring as "a fallback in case internal systems completely fail" (metamonitoring). That advice applies to every option in this post.
A Homegrown Checker, and Who Watches It#
The smallest build is a table of last-success times and a cron job that reads it. With SQLite:
-- /var/lib/jobwatch/schema.sql, loaded once: sqlite3 /var/lib/jobwatch/jobs.db < schema.sql
CREATE TABLE IF NOT EXISTS jobs (
name TEXT PRIMARY KEY,
max_age_seconds INTEGER NOT NULL,
last_success INTEGER
);
INSERT OR IGNORE INTO jobs (name, max_age_seconds) VALUES ('nightly-backup', 93600);Each job's wrapper records a success:
#!/bin/sh
# /opt/backup-recorded.sh: run the backup, record the time of a successful run.
/opt/backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then
sqlite3 -cmd ".timeout 5000" /var/lib/jobwatch/jobs.db \
"UPDATE jobs SET last_success = CAST(strftime('%s', 'now') AS INTEGER) WHERE name = 'nightly-backup';"
fi
exit "$rc"A checker runs every 15 minutes from cron (*/15 * * * * /usr/local/bin/jobwatch-check):
#!/bin/sh
# /usr/local/bin/jobwatch-check: mail the names of overdue jobs.
overdue=$(sqlite3 -cmd ".timeout 5000" /var/lib/jobwatch/jobs.db \
"SELECT name FROM jobs
WHERE last_success IS NULL
OR CAST(strftime('%s', 'now') AS INTEGER) - last_success > max_age_seconds;")
if [ -n "$overdue" ]; then
printf 'Overdue on %s:\n%s\n' "$(hostname)" "$overdue" |
mail -s "Overdue cron jobs" [email protected]
fiWhat it lacks:
- The schedule. A fixed age has the same weekday problem as a Prometheus rule.
- Alert state. It mails every 15 minutes while a job is overdue and says nothing when the job recovers. Reminders, acknowledgment, and recovery, the lifecycle cron job alerting describes, are where scripts grow into products.
- Delivery.
mailsucceeds once the host's mail system accepts the message. Whether the relay delivers it happens later, out of the checker's sight. - Independence. If the host goes down, the jobs stop and so does the checker. If cron stops, both stop. The failures it most needs to report are the ones that silence it.
The fix for the last one is something on other infrastructure that expects a signal from the checker and alerts when it stops: a dead man's switch. At that point the piece that matters most runs somewhere else anyway.
What You Own When You Self-Host#
- The monitor's uptime: every process supervised through crashes and reboots.
- Alert delivery: the SMTP relay, chat apps, an SMS provider, and egress to each.
- Upgrades and patches, including migration steps. Healthchecks 4.4 (August 31, 2026) moved to Django 6.1, which dropped PostgreSQL before 15, MySQL before 8.4, and MariaDB before 10.11, and 5.0 needs a new
commandin customized Docker Compose files. Uptime Kuma shipped eight 2.5.x releases from August 1 to October 23, 2026. - Exposure: TLS, proxy headers, and who can reach the ping URLs and dashboard.
- Backups, with tested restores.
- A failure domain separate from the jobs, which decides whether the rest is worth it.
The Monitor Must Fail Separately From the Jobs#
A cron monitor's job is to notice silence. If it shares a host, a cluster, network egress, a power feed, a cloud account or region, or an SMTP relay with the jobs, the outage that silences the jobs silences the monitor too. A self-hosted monitor in the same failure domain reports a total outage the way it reports a quiet night: with nothing.
Health checks prove less than they suggest. The Healthchecks image's Docker health check calls /health (Dockerfile), which runs SELECT 1 against the database (middleware.py). That proves the web app reaches its database, not that alerts reach anyone. Prometheus's guidance points the same way: test the whole path, end to end.
A sound self-hosted setup adds:
- A different place. Another provider, region, or site from the jobs it watches.
- An outside watcher. A heartbeat from the monitor's host to a dead man's switch elsewhere; for Prometheus, the always-firing Watchdog alert in the Prometheus guide.
- A drill. On a schedule, stop pinging a test check and confirm the alert reaches a person.
Step 2 means a second monitor, hosted or on a second site. That isn't an argument against self-hosting; it's part of the cost.
Count the Cost in Engineer Time#
No general figure fits your team; these questions produce one:
- Who is paged when the monitor itself is down, and how would they find out?
- Who reads each release's notes and runs its migration steps, and how soon after a security fix?
- Which alert channels depend on credentials or apps someone must keep valid?
- When did you last restore the monitor's database, and how long did it take?
- Against a hosted bill at your job count, are those hours cheaper?
Self-Host or Use a Hosted Monitor#
Self-Host When#
- The network is air-gapped or has no outbound internet. A hosted service can't receive pings that can't leave.
- Job data must stay in your network. Healthchecks stores ping request bodies, up to
PING_BODY_LIMIT, 10,000 bytes by default, so job output can land in the monitor. - You already run Prometheus with on-call for it. The stack and its owner exist; add the Watchdog if it's missing.
- It's a hobby or homelab. The stakes are low, and Uptime Kuma adds uptime checks and status pages in the same tool.
Use a Hosted Monitor When#
- Nobody is on call for the monitor. A hosted service is someone else's to keep running.
- You need a separate failure domain without building a second site.
- The team is small, and the hours above are better spent on the jobs.
A hosted monitor isn't immune to outages: its availability is in the vendor's hands, so check what the vendor publishes.
Where CronRadar Fits, and Where Healthchecks Fits Better#
CronRadar is hosted only; there's no self-hosted edition. Jobs check in to https://cron.life with a heartbeat, or with start, complete, and fail. CronRadar reads a five-field cron schedule in UTC and waits a grace period, 90 seconds by default and 60 minimum, before a late run goes from warning to critical. A fail alerts immediately, and a start with no finish is flagged as stuck once the next expected run plus grace passes. Alerts go to five channels: email, Slack, webhook, Discord, and PagerDuty. Shell scripts and crontab have no CronRadar SDK or auto-discovery, so each job reports with its own HTTP check-in; the Hangfire and Quartz.NET 3.x extensions discover recurring jobs.
Free covers up to 10 active monitors with every alert channel and no card. Grow: the first 10 active monitors stay free; each further monitor is billed monthly by schedule class, $2 Fast (gap under 5 minutes), $1 Frequent (5 minutes to under an hour), $0.50 Standard (hourly or less often), in arrears on the month's peak, with no tiers, minimum, or monitor limit, and an optional monthly budget that holds new monitors paused. Business is for custom requirements. Runs, retries, and API calls are never billed (pricing). CronRadar offers no contractual SLA and publishes no availability figure.
Healthchecks fits better when:
- You need to self-host, now or later. CronRadar can't run in your network; Healthchecks is available hosted or self-hosted.
- Schedules use a local time zone or systemd OnCalendar. CronRadar evaluates five-field cron in UTC only.
- You want a per-run time limit. Healthchecks' grace after a start signal is one. CronRadar's stuck detection is per job, with no maximum-runtime setting.
- You need SMS, phone calls, WhatsApp, Signal, or Telegram. CronRadar has five channels and no SMS.
- A fixed plan suits your job count. On healthchecks.io, Hobbyist is free for 20 jobs, Business is $20 a month or $192 a year for 100 jobs, and Business Plus is $80 a month or $768 a year for 1,000 jobs (checked October 25, 2026, pricing). For 100 jobs on hourly or slower schedules, CronRadar's bill is $45 a month (90 paid Standard monitors at $0.50), $25 more than Business.
Uptime Kuma fits better if you want uptime checks and status pages next to job heartbeats in one self-hosted tool, which CronRadar doesn't do. Cron monitoring tools compared puts the hosted options side by side.
Frequently Asked Questions#
Can I self-host cron monitoring?#
Yes. Healthchecks is an open-source cron monitor you can run yourself, Uptime Kuma's push monitors work for jobs on a fixed interval, and Prometheus can alert on last-success timestamps. You then own its uptime, upgrades, and alert delivery, and it should run outside the jobs' failure domain.
Is Healthchecks.io open source?#
Yes. The Healthchecks project is licensed under the BSD 3-Clause license and is also available as a hosted service at healthchecks.io. Version 5.0.1 was released October 22, 2026.
Is Uptime Kuma enough for self-hosted cron monitoring?#
For jobs on a steady interval, often yes: a Push monitor goes down when no push arrives within its Heartbeat Interval, after any retries. In version 2.5.7 that interval is a number of seconds, not a cron expression, and there's no start signal, so irregular schedules and hung runs are only approximated. Uptime Kuma cron job monitoring covers the setup.
Who monitors a self-hosted monitor?#
Something outside it: an external dead man's switch fed from the monitor's host, a Watchdog alert for Prometheus, and a regular drill that confirms an alert reaches a person. A health check that only tests the database doesn't prove alerts go out.
Should I build my own cron monitoring?#
Only if you'll also run something outside it to watch the checker. A table of last-success times and a checker cron job is small to write, but it lacks schedule awareness, alert state, and recovery messages, and if its host or cron daemon stops, it stops with the jobs.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.