← Recipes

阅读中文版本

Monitor Cron Jobs with Dead-Man's-Switch Heartbeats

Send a heartbeat only after a scheduled backup succeeds, and alert when the heartbeat is late, so silent cron failures are detected within the expected window.

~35 mincronbackupsmonitoring

Task

Add success-only heartbeat pings to a scheduled PostgreSQL backup and configure a dead-man's-switch check that alerts when runs stop.

When to use this

A nightly billing backup stopped running after a deploy, but the cron daemon reported success and nobody noticed the missing files for three days.

Prerequisites

  • A Linux host with bash, cron, and PostgreSQL 15+ client tools
  • A Healthchecks.io account with a read-write API key, or a self-hosted Healthchecks instance
  • Outbound HTTPS access to the Healthchecks ping endpoint
  • An alert integration such as email, Slack, or PagerDuty attached to the check

Steps

1. Create a cron-type check with a two-hour grace period and capture the returned ping URL.

curl -sS -X POST https://healthchecks.io/api/v3/checks/ \
  -H "X-Api-Key: $HEALTHCHECKS_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name":"nightly-billing-export","tags":"billing,cron","schedule":"0 2 * * *","tz":"UTC","grace":900}' \
  | jq '{name, schedule, grace, ping_url}'

Expected output: HTTP 201 with a JSON object containing name nightly-billing-export, schedule 0 2 * * *, grace 900, and a unique ping_url.

2. Create a backup script that pings only after pg_dump, a size check, and retention cleanup all succeed; a failure sends an explicit fail signal.

#!/usr/bin/env bash
set -euo pipefail

set -a
. /etc/billing-backup.env
set +a

PING_URL="${HEALTHCHECKS_PING_URL:?missing HEALTHCHECKS_PING_URL}"
OUT="/srv/backups/billing-$(date -u +%F).dump"

trap 'curl --fail --silent --max-time 10 "$PING_URL/fail" >/dev/null || true' ERR

pg_dump "$DATABASE_URL" --format=custom --no-owner --file="$OUT"
test -s "$OUT"
find /srv/backups -name 'billing-*.dump' -mtime +14 -delete

curl --fail --silent --show-error --max-time 10 --retry 3 "$PING_URL" >/dev/null

Expected output: A successful run creates a non-empty .dump file, deletes archives older than 14 days, and sends one success ping; any failed command sends /fail instead.

3. Install the script, write the two secrets into a root-only environment file, and add the cron entry without dropping existing jobs.

sudo install -m 0755 nightly-billing-backup.sh /usr/local/bin/nightly-billing-backup.sh
sudo install -m 0600 /dev/null /etc/billing-backup.env
sudo env DATABASE_URL="$DATABASE_URL" HEALTHCHECKS_PING_URL="$HEALTHCHECKS_PING_URL" \
  sh -c 'printf "DATABASE_URL=%s\nHEALTHCHECKS_PING_URL=%s\n" "$DATABASE_URL" "$HEALTHCHECKS_PING_URL" > /etc/billing-backup.env'

(sudo crontab -l 2>/dev/null; echo '0 2 * * * /usr/local/bin/nightly-billing-backup.sh >> /var/log/billing-backup.log 2>&1') | sudo crontab -
sudo crontab -l | grep nightly-billing-backup

Expected output: crontab prints the nightly-billing-backup line at 02:00, and /etc/billing-backup.env is mode 0600 with both values present.

4. Run the script once, inspect the archive, and use the fail and success endpoints to test the alert path without waiting for a missed schedule.

sudo /usr/local/bin/nightly-billing-backup.sh
latest=$(ls -t /srv/backups/billing-*.dump | head -1)
pg_restore --list "$latest" | head -5
curl -fsS "$HEALTHCHECKS_PING_URL/fail" >/dev/null
curl -fsS "$HEALTHCHECKS_PING_URL" >/dev/null

Expected output: pg_restore lists a valid archive, the /fail ping triggers the configured down alert, and the success ping resets the check.

5. Confirm the check status and last ping time through the Healthchecks API.

curl -sS -H "X-Api-Key: $HEALTHCHECKS_API_KEY" https://healthchecks.io/api/v3/checks/ \
  | jq -r '.checks[] | select(.name == "nightly-billing-export") | [.status, .last_ping] | @tsv'

Expected output: The row prints up followed by a recent ISO-8601 timestamp, and the dashboard shows the check in the up state.

Troubleshooting

Problem

The check alerts every day even though cron runs

Solution

Compare the cron timezone with the check timezone. Set CRON_TZ=UTC or change the check schedule; also verify that the job finishes inside the grace window.

Problem

No alert arrives when the job fails

Solution

Test /fail manually, confirm the integration is attached to this specific check, and verify the API key belongs to the same project.

Problem

The script works by hand but not from cron

Solution

Cron does not load the login profile. Source /etc/billing-backup.env from the script and use absolute paths for pg_dump, curl, and the backup directory.

Problem

The backup file exists but the heartbeat is missing

Solution

The final curl failed, likely because outbound HTTPS is blocked or the ping URL is wrong. With set -e, the job exits non-zero; check /var/log/billing-backup.log.

Limitations

  • A heartbeat proves the job ran, not that the backup is restorable; pair it with a periodic pg_restore verification.
  • The ping URL is a bearer secret; anyone who obtains it can fake success.
  • Healthchecks.io is an external dependency; self-host it for air-gapped or regulated environments.

Absence of an error is not success

Cron reports the exit status of the command it launched, but it cannot tell you that the command never launched, that the host was rebuilt without the crontab, or that the job exited before writing a file. A dead-man’s-switch check inverts the signal: the job must prove it completed. If the expected ping does not arrive by the schedule plus grace period, the monitoring service alerts. That catches a deleted crontab, a stopped instance, an expired credential, and a job that hangs before its first log line.

Use a success-only heartbeat rather than a start ping. A start ping proves the job began, which is the least interesting part; it can still hang or fail after starting. The script pings after pg_dump, after a non-empty size check, and after retention cleanup. If any step fails, set -e aborts and the ERR trap sends an explicit failure. The fail signal is optional but useful: it shortens detection from the grace period to seconds.

The schedule and grace are part of the contract

A cron check needs a schedule in the monitoring service, not just in the local crontab. Set both to the same time and timezone. Healthchecks uses the check timezone to decide when a ping is due; if the host runs in local time and the check is UTC, the alert can fire during a normal run or miss a late one. Grace should cover the longest normal runtime plus a margin, not the entire day. A two-hour backup with a 15-minute grace alerts quickly when the job is genuinely missing.

Do not send a heartbeat from the cron wrapper before the backup finishes, and do not send one from a separate process that only checks whether the file exists. The signal must come from the same execution path that produced the artifact. Otherwise a partially written file, a failed pg_dump, or a full disk can still produce a green check.

Test the alert path

The /fail endpoint is the fastest way to verify the integration. It should trigger the same notification path as a missed ping, and the next success ping should reset the check. Do this during onboarding, not during an incident. Then run the actual script once and inspect the archive with pg_restore --list; a heartbeat with an invalid dump is worse than no heartbeat because it creates false confidence.

Check the API status after the test. The status should be up and last_ping should be recent. If the status is grace or down, read the check’s ping history and compare it with the cron log. A single late ping is often a long-running backup; repeated late pings mean the grace period is too tight or the job is degrading.

Pitfalls

Do not put the ping URL in the crontab, where it is visible in process listings and backups. Keep it in the root-only environment file. Do not use curl without --fail; an HTTP 500 from the ping service would otherwise look like success. Do not rely on a heartbeat as proof of restorability; schedule a restore drill separately. And do not let the monitoring check become the only alert for backup failures; a failed dump should also page through the job’s own error handling when the failure is actionable.

Example

#!/usr/bin/env bash
set -euo pipefail

set -a
. /etc/billing-backup.env
set +a

PING_URL="${HEALTHCHECKS_PING_URL:?missing HEALTHCHECKS_PING_URL}"
OUT="/srv/backups/billing-$(date -u +%F).dump"

trap 'curl --fail --silent --max-time 10 "$PING_URL/fail" >/dev/null || true' ERR

pg_dump "$DATABASE_URL" --format=custom --no-owner --file="$OUT"
test -s "$OUT"
find /srv/backups -name 'billing-*.dump' -mtime +14 -delete

curl --fail --silent --show-error --max-time 10 --retry 3 "$PING_URL" >/dev/null