All posts

June 14, 2026 6 min read

How to monitor bash cron scripts properly

Checking exit codes in bash cron scripts is not enough. Here's how to detect missed runs, hung scripts, and silent failures in shell-based scheduled jobs — without setting up a logging pipeline.


Bash cron scripts are the most common form of scheduled job in production infrastructure. Database backups, log rotation, S3 syncs, disk cleanup — most of these start life as a shell script and a crontab entry.

They're also among the least monitored. The conventional approach — redirecting output to a log file and checking for error strings — requires someone to read the log, which means nobody reads it until something breaks. Exit code checks catch crashes. They don't catch missed runs (the server rebooted and the crontab was lost), hung scripts (the rsync is still running three days later), or silent failures (the backup ran but produced an empty archive).

Here's how to add proper monitoring to bash cron scripts without a logging pipeline.


The minimal pattern

Add three curl calls to any bash script: one at the start, one on success, one on failure.

#!/bin/bash
set -euo pipefail

MONITOR_ID="your-monitor-id"
API_KEY="${CRONTIFY_API_KEY}"
BASE_URL="https://api.crontify.com/api/v1/ping/${MONITOR_ID}"

# Send a monitoring ping — never fails the script
ping() {
  curl -fsS -X POST \
    -H "X-API-Key: ${API_KEY}" \
    "${BASE_URL}/${1}" \
    ${2:+-H "Content-Type: application/json" -d "${2}"} \
    > /dev/null 2>&1 || true
}

ping "start"

# Your job logic goes here
result=$(your_job_command 2>&1)
exit_code=$?

if [ $exit_code -eq 0 ]; then
  ping "success"
else
  # Send failure with the last 2000 chars of output as log
  log_payload=$(printf '{"message":"Exit code %s","log":"%s"}' \
    "$exit_code" \
    "$(echo "$result" | tail -c 2000 | sed 's/"/\\"/g' | tr '\n' ' ')")
  ping "fail" "$log_payload"
  exit $exit_code
fi

The || true at the end of the curl call is critical. It prevents a failed ping (network timeout, Crontify outage) from causing the script to exit non-zero. Your monitoring must never be in the critical path of your actual job.


Attaching output count for silent failure detection

For scripts that process data, attach a record count to the success ping. This enables alert rules in Crontify's dashboard that fire when the script ran but processed nothing.

#!/bin/bash
set -euo pipefail

MONITOR_ID="your-monitor-id"
API_KEY="${CRONTIFY_API_KEY}"
BASE_URL="https://api.crontify.com/api/v1/ping/${MONITOR_ID}"

ping() {
  curl -fsS -X POST \
    -H "X-API-Key: ${API_KEY}" \
    "${BASE_URL}/${1}" \
    ${2:+-H "Content-Type: application/json" -d "${2}"} \
    > /dev/null 2>&1 || true
}

ping "start"

# Sync files and capture count
FILE_COUNT=$(aws s3 sync /data s3://your-bucket/backup \
  --exclude "*.tmp" \
  2>&1 | grep -c "upload:" || true)

BACKUP_SIZE=$(du -sb /data | cut -f1)

# Send success with metadata
ping "success" "{\"meta\":{\"files_synced\":${FILE_COUNT},\"backup_bytes\":${BACKUP_SIZE}}}"

In Crontify's dashboard, define rules:

  • files_synced eq 0 → alert (nothing was synced despite running)
  • backup_bytes lt 1000 → alert (backup archive is suspiciously small)

A reusable wrapper for multiple scripts

If you have several cron scripts to instrument, a sourced helper avoids repeating the curl logic in every script:

# /usr/local/lib/crontify.sh

CRONTIFY_BASE_URL="https://api.crontify.com/api/v1/ping"

crontify_ping() {
  local monitor_id="$1"
  local event="$2"
  local payload="${3:-}"

  curl -fsS -X POST \
    -H "X-API-Key: ${CRONTIFY_API_KEY}" \
    ${payload:+-H "Content-Type: application/json" -d "${payload}"} \
    "${CRONTIFY_BASE_URL}/${monitor_id}/${event}" \
    > /dev/null 2>&1 || true
}

# Run a command with monitoring
# Usage: crontify_wrap <monitor_id> <command> [metadata_json]
crontify_wrap() {
  local monitor_id="$1"
  shift

  crontify_ping "$monitor_id" "start"

  local output
  local exit_code=0
  output=$("$@" 2>&1) || exit_code=$?

  if [ $exit_code -eq 0 ]; then
    crontify_ping "$monitor_id" "success"
  else
    local log_excerpt
    log_excerpt=$(echo "$output" | tail -c 2000 | sed 's/"/\\"/g' | tr '\n' ' ')
    crontify_ping "$monitor_id" "fail" \
      "{\"message\":\"Exit code ${exit_code}\",\"log\":\"${log_excerpt}\"}"
  fi

  return $exit_code
}

Usage in any script:

#!/bin/bash
source /usr/local/lib/crontify.sh

crontify_wrap "your-monitor-id" /usr/local/bin/your-backup-script.sh

Environment variables in crontab

Cron runs with a minimal environment. Variables you set in your shell profile are not available to cron jobs. Set environment variables explicitly in the crontab:

CRONTIFY_API_KEY=ck_live_your_key
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin

Nightly backup

0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Or load them from a file at the start of your script:

# Load environment
set -a
source /etc/cron-env
set +a

Keep /etc/cron-env readable only by root or the cron user:

chmod 600 /etc/cron-env

Common failure modes this catches

Server reboot without crontab restore. The system crontab is restored on boot, but if you manually added jobs to a user crontab and that user's crontab wasn't backed up, the jobs stop running. The missed start ping alerts within the grace period.

Script hanging on a network call. rsync, aws s3 sync, or curl to a slow endpoint can hang indefinitely if no timeout is configured. The start ping arrives; the success ping doesn't. Crontify fires a hung job alert after the maximum duration threshold.

S3 sync with zero files. The bucket exists and the command succeeds, but the source directory was empty or the filter excluded everything. The files_synced eq 0 rule catches this.

Backup producing an empty archive. tar exits 0 even if it compressed nothing. The backup_bytes lt 1000 rule catches an archive that's smaller than any real backup should be.


Crontify is free for up to 5 monitors — no credit card required. HTTP pings work in any shell script without installing any additional packages.


Start monitoring your scheduled jobs

Free plan includes 5 monitors. No credit card required. Up and running in under 5 minutes.

Get started free →