LucidStack
Features
Solutions
Resources
Pricing
Guide #cron #heartbeat #laravel

How to monitor a cron job with heartbeats

Cron jobs fail silently. Learn to monitor them with heartbeats: curl pings, duration, exit codes, grace periods, time zones and a Laravel example.

The LucidStack team lucidstack.dev
Published
Updated
Reading time
7 min read
On this page
  1. Why cron jobs fail silently
  2. How heartbeat monitoring works
  3. The simplest ping: one crontab line
  4. && versus ;: one character that changes everything
  5. Tracking duration and exit code
  6. Telling concurrent runs apart with ?rid=
  7. Sending the job's output
  8. Setting the expected schedule
  9. Simple period or cron expression
  10. Time zone
  11. Grace period
  12. Maximum duration
  13. With the Laravel scheduler
  14. Common pitfalls
  15. PATH in cron
  16. The % character
  17. Overlapping runs
  18. What to monitor first
  19. Setting up your first heartbeat

A backup that quietly stopped running three weeks ago, a product import stuck since Tuesday, invoices that never went out on the 1st: failed scheduled jobs all share one trait, nobody notices them. No visitor hits an error page, no HTTP check turns red. You find out on the day you actually need the result.

This guide shows how to monitor a cron job with a heartbeat, from a one-line ping at the end of a crontab entry to fine-tuning the expected schedule, plus the pitfalls that catch almost everyone. The examples use LucidStack, but the approach works with any tool of this kind.

Why cron jobs fail silently

cron starts commands, nothing more. It doesn't check whether they succeeded and never retries. Its only error reporting is a local email sent to MAILTO, which lands in a mailbox nobody reads, or nowhere at all if the server has no mail setup. Add the usual > /dev/null 2>&1 and every trace is gone.

The causes vary:

  • the server was rebuilt or replaced, and the crontab didn't come along;
  • the cron daemon is stopped, or the container that should run it never starts it;
  • the script crashes on a missing dependency, a full disk or an expired credential;
  • the job runs, but far too long, and starts overlapping with the next run.

In every case the symptom is the same: something that should have happened didn't. Traditional monitoring detects the presence of an error, not the absence of an event.

How heartbeat monitoring works

A heartbeat flips the logic. Instead of probing the job, the job checks in: on every run it sends an HTTP request to its own unique URL. The monitoring service knows the expected schedule, and if the signal doesn't arrive on time, or reports a failure, it alerts you.

There's no port to open and no agent to install. The connection is outbound, so it works behind a firewall or NAT, from any language that can make an HTTP request.

In LucidStack, each heartbeat has a unique ping URL of the form https://ping.lucidstack.dev/<token>, with a 32-character token. GET, POST and HEAD are all accepted. Replace the URL in the examples below with your own.

The simplest ping: one crontab line

bash
# Back up every day at 03:00, then check in if the backup succeeded
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://ping.lucidstack.dev/k3x9q2m7v8w1z4r6t0y5u2p8n3b7c1d9 > /dev/null

Each curl flag has a job:

Flag What it does
-f Treats an HTTP error response as a failure instead of ignoring it
-s Silent mode: no progress meter cluttering your logs
-S Still shows error messages despite -s
-m 10 Gives up after 10 seconds, so a slow network never hangs the job
--retry 3 Retries up to three times on transient errors

Redirecting to /dev/null discards the server's response but keeps stderr, so if the ping itself fails you'll still see it in your logs.

&& versus ;: one character that changes everything

With &&, curl only runs if the backup exits with code 0. If it fails, no ping is sent and you're alerted once the grace period runs out. With ;, curl runs no matter what, and the heartbeat happily reports success right after the backup failed.

If you prefer the semicolon, pass the exit code along:

bash
0 3 * * * /usr/local/bin/backup.sh; curl -fsS -m 10 --retry 3 "https://ping.lucidstack.dev/k3x9q2m7v8w1z4r6t0y5u2p8n3b7c1d9?exit_code=$?" > /dev/null

Any non-zero code is recorded as a failure, and the alert goes out immediately instead of waiting for the grace period.

Tracking duration and exit code

To know how long each run takes, send two signals: /start at the beginning, then the bare URL (or /complete) at the end. The /fail suffix closes the run as a failure. A small wrapper script handles all of it:

bash
#!/usr/bin/env bash
PING="https://ping.lucidstack.dev/k3x9q2m7v8w1z4r6t0y5u2p8n3b7c1d9"
RID=$(uuidgen)
LOG=$(mktemp)

curl -fsS -m 10 --retry 3 "$PING/start?rid=$RID" > /dev/null

/usr/local/bin/backup.sh > "$LOG" 2>&1
CODE=$?

# Send the last 10 KB of output as the request body
tail -c 10000 "$LOG" | curl -fsS -m 10 --retry 3 --data-binary @- \
  "$PING?exit_code=$CODE&rid=$RID" > /dev/null

rm -f "$LOG"
exit $CODE

Telling concurrent runs apart with ?rid=

When the same job can run several times in parallel, on several servers or because one run spills into the next, the service can't guess which end signal belongs to which /start. The rid parameter (run ID) ties them together. Any unique value works; a UUID is the easy choice.

Sending the job's output

The request body, up to 10 KB, is stored as the run's output and kept for 90 days. The /log suffix also lets you attach text without closing the run, for instance after an intermediate step. When an alert arrives, the last lines of the log are already in front of you, no SSH session needed.

Setting the expected schedule

Simple period or cron expression

A period (every hour, every day) is enough when the exact time doesn't matter, for example with a systemd timer. For a real cron job, reuse its expression instead: 0 3 * * 1-5 doesn't run at weekends, and a 24-hour period would raise a false alarm every Saturday. Our cron expression tool lists the next runs in the time zone of your choice, which is handy for checking that an expression means what you think it means.

Time zone

cron uses the server's time zone, which is often UTC on cloud machines. If your crontab says 03:00 UTC and the heartbeat expects 03:00 in Paris, you'll be off by one or two hours depending on the season, and get alerts for nothing. Set the heartbeat to the same time zone as the machine. Watch out for daylight saving changes too: depending on the cron implementation, a job scheduled between 02:00 and 03:00 may be skipped, shifted or run twice on those days. Avoid that window, or run the server in UTC.

Grace period

The grace period is how long to wait after the expected time before alerting. If you only ping at the end, it must cover the normal run time: a backup starting at 03:00 that takes 20 minutes checks in around 03:20, so a 40-minute grace period leaves room. Too short and you get false positives; too long and every alert arrives late.

Maximum duration

With a /start ping you can also set a maximum duration. If the run hasn't finished in time, you're alerted even though the job is still running. That's how you catch a job stuck on a lock or on a network call with no timeout.

With the Laravel scheduler

Laravel's scheduler has built-in ping methods, driven by the command's exit code:

php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

$ping = 'https://ping.lucidstack.dev/k3x9q2m7v8w1z4r6t0y5u2p8n3b7c1d9';

Schedule::command('backup:run')
    ->dailyAt('03:00')
    ->timezone('Europe/Paris')
    ->withoutOverlapping()
    ->pingBefore("{$ping}/start")
    ->pingOnSuccess($ping)
    ->pingOnFailure("{$ping}/fail");

Don't forget the link everything depends on: the * * * * * php artisan schedule:run line in the crontab. If it disappears during a server migration, nothing runs and nothing pings. A heartbeat dedicated to the scheduler itself removes that blind spot:

php
Schedule::call(fn () => true)
    ->everyFiveMinutes()
    ->thenPing('https://ping.lucidstack.dev/a7c2e9g4j1l6n8p3r5t0v2x7z9b4d6f1');

The app provides copy-paste snippets for crontab, Bash, PHP, Laravel, Node.js and Python.

Common pitfalls

PATH in cron

cron starts with a minimal environment: a PATH often limited to /usr/bin:/bin, and neither .bashrc nor .profile is loaded. A script that works by hand then fails with "command not found". Use absolute paths, or set PATH at the top of the crontab. The same applies to environment variables your shell normally loads.

bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

The % character

In a crontab, % means a newline, so a command like date +%F gets cut in half. Escape it: date +\%F.

Overlapping runs

A job scheduled every ten minutes that sometimes takes fifteen will eventually run twice at once. flock guarantees a single run at a time:

bash
*/10 * * * * flock -n /var/lock/import.lock /usr/local/bin/import.sh && curl -fsS -m 10 --retry 3 https://ping.lucidstack.dev/k3x9q2m7v8w1z4r6t0y5u2p8n3b7c1d9 > /dev/null

With -n, if the previous run still holds the lock, flock exits straight away with an error: no ping, so you hear about it.

What to monitor first

  • Backups: database dumps, off-site copies.
  • Imports and syncs: product catalogue, stock levels, CRM, partner feeds.
  • Billing: invoice generation, payment retries, reminders.
  • Queues: schedule a tiny job every five minutes that goes through the queue and pings once processed. If the workers are down or the queue is backed up, the ping goes missing.
  • Housekeeping: certificate renewal, log cleanup, sitemap generation.

For the queue, the job only takes a few lines:

php
// app/Jobs/QueueHeartbeat.php (excerpt)
public function handle(): void
{
    Http::timeout(10)->retry(3, 1000)->get('https://ping.lucidstack.dev/<token>');
}

// routes/console.php
Schedule::job(new QueueHeartbeat)->everyFiveMinutes();

Setting up your first heartbeat

Start with the job whose failure would hurt most, usually the backup. With LucidStack, cron job monitoring takes a few minutes to set up: schedule, grace period, maximum duration, then the curl line to paste. Late, failed and overrunning jobs trigger alerts on the alert channels of your choice, with the duration, exit code and job output attached. The Free plan includes 3 heartbeats with no credit card required; the other plans are listed on the pricing page.

Start monitoring in two minutes.

Add a URL, choose where to be alerted. LucidStack takes care of the rest, day and night.