How to Know If Your Cronjobs Are Failing Silently
Your cronjob has been "working" for months. It shows up in crontab -l, the server is on, nothing strange. But when you open the logs, the last entry is from four weeks ago. Or worse: when you need to restore a backup, there is nothing to restore.
Silent cronjobs are one of the most dangerous problems in infrastructure precisely because there is no alarm signal. You don't see an error. You don't receive an email. Just absence.
This guide explains how to detect if your cronjobs are failing, how to diagnose the cause, and how to prevent it from happening again.
Why cronjobs fail silently
Before detecting the problem, it's worth understanding why it happens.
1. Cron's environment is not your user environment
This is the most frequent culprit. When you run a script manually, you have your PATH, your environment variables, and your aliases. Cron starts with a minimal and clean environment.
If your script calls rclone, restic, python3, or any other binary you installed in /home/user/.local/bin or /usr/local/bin, it may work perfectly when you run it and fail silently when cron runs it, because that directory is not in cron's PATH.
Quick fix: use absolute paths in your scripts.
# Bad — depends on the user's PATH
rclone sync /data remote:backup/
# Good — absolute path
/usr/bin/rclone sync /data remote:backup/
2. Relative paths in the script
A script that works from /home/user/scripts/ may fail when cron runs it because the working directory is not what you expect.
# At the beginning of your script — set the directory explicitly
cd /home/user/scripts || exit 1
3. The destination is not available
If your backup goes to a NAS, an S3 bucket, or a remote server, any connectivity problem can make the script "finish correctly" (exit 0) without having copied anything real. Many backup tools don't return an error if the destination is not available — they simply don't copy anything.
4. Permissions
A script that you run as your user may need permissions that the cron user (often root or the owner of the crontab) doesn't have over certain files or directories.
5. Cron itself is not running
Infrequent but it happens: the cron or crond service may have stopped after an update or a system restart. If no one monitors it, no one knows.
How to diagnose a cronjob that isn't working
Step 1: Verify that cron is running
systemctl status cron # Debian/Ubuntu
systemctl status crond # RHEL/CentOS/Fedora
If it's not active, starting it is easy:
systemctl start cron && systemctl enable cron
Step 2: Check cron logs
# View the latest executions
grep CRON /var/log/syslog | tail -50
# On systems with journald
journalctl -u cron --since "2 days ago"
journalctl -u crond --since "2 days ago"
Look for lines with your script's name. If they don't appear when they should, cron isn't running it. If they appear but the script fails, you'll see the exit code.
Step 3: Force manual execution with cron's environment
The most reliable way to reproduce the failure is to run the script with the same environment that cron uses:
# Run the script with a minimal environment, like cron does
env -i HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash /path/to/your/script.sh
If it fails here and works normally, the problem is the environment. Check the PATH and the variables your script uses.
Step 4: Capture the script's output
Cron discards all output by default (or sends it by local email if you have MAILTO configured, which nobody usually has). Redirect the output to a log:
# In your crontab
0 2 * * * /home/user/backup.sh >> /var/log/backup.log 2>&1
The 2>&1 also redirects stderr. With this, the next time the script runs (or fails), you'll have a record of exactly what happened.
Step 5: Add set -euo pipefail to your scripts
Without this, a bash script continues executing even if a command fails. With these options:
#!/bin/bash
set -euo pipefail
# The script stops on the first error
rsync -av /data/ /backup/ || exit 1
rclone sync /data remote:bucket/
echo "Backup completed: $(date)"
set -e: exits if any command returns an error codeset -u: treats undefined variables as errorsset -o pipefail: propagates errors through pipes
The real solution: don't depend on everything going well
Debugging cronjobs is useful, but not enough. The underlying problem is that the "if there's no error, everything is fine" model doesn't work for critical scheduled tasks.
The correct solution is to invert the logic: instead of waiting for something to go wrong and finding out, your script actively notifies when it finishes well. If that notification doesn't arrive, you know something went wrong.
This is exactly what heartbeats are for.
How to configure heartbeats for your cronjobs
The basic implementation
Add an HTTP call at the end of your script, after all the important stuff has finished:
#!/bin/bash
set -euo pipefail
# Your backup logic here
rclone sync /data remote:bucket/
# We only get here if everything worked — send the heartbeat
curl -fsS https://securyblack.com/api/heartbeat/YOUR_ID > /dev/null
With set -e, if any step fails, the script stops before the curl. The signal doesn't arrive. You receive an alert.
If the server is off and can't run the cronjob, the signal doesn't arrive either. You receive an alert.
Configure the grace period
On SecuryBlack Heartbeats you configure how much time can pass without receiving the signal before considering it a problem. If your backup normally takes 20-40 minutes, a grace period of 90 minutes covers normal variations without generating false alarms.
Heartbeat with duration measurement
If you want to know how long each execution takes, also send a start signal:
#!/bin/bash
set -euo pipefail
# Start signal (optional, to measure duration)
curl -fsS "https://securyblack.com/api/heartbeat/YOUR_ID/start" > /dev/null
# Your backup logic
rclone sync /data remote:bucket/
# End signal — confirms everything went well
curl -fsS "https://securyblack.com/api/heartbeat/YOUR_ID" > /dev/null
Complete example: backup with rclone
#!/bin/bash
set -euo pipefail
LOG="/var/log/backup-$(date +%Y%m%d).log"
HEARTBEAT_ID="YOUR_ID_HERE"
echo "[$(date)] Starting backup..." | tee -a "$LOG"
# Sync to S3 bucket
/usr/bin/rclone sync /data remote:my-bucket/backup/ \
--log-file="$LOG" \
--log-level INFO
echo "[$(date)] Backup completed." | tee -a "$LOG"
# Heartbeat only if we get here
curl -fsS "https://securyblack.com/api/heartbeat/${HEARTBEAT_ID}" > /dev/null
Other cronjobs that deserve a heartbeat
Backups are the most obvious example, but any scheduled task whose silent failure would have consequences deserves a heartbeat:
- Synchronization scripts between databases or servers
- Log rotation — if it doesn't rotate, the disk fills up
- Automatic updates of blacklists, certificates, or local databases
- Notification scripts — if you send a daily summary and one day it doesn't arrive, you want to know why
- Database maintenance tasks — VACUUM, ANALYZE, periodic optimizations
- Report generation — if someone expects that report every Monday
The rule: if a cronjob can fail and no one would know until it's too late, it needs a heartbeat.
Summary: checklist for robust cronjobs
- [ ]
set -euo pipefailat the beginning of each bash script - [ ] Absolute paths to all binaries
- [ ] Explicit working directory (
cd /path || exit 1) - [ ] Output redirected to a log (
>> /var/log/script.log 2>&1) - [ ] Heartbeat at the end of each critical script
- [ ] Grace period configured with reasonable margin
- [ ] Cron and crond enabled to start with the system
Want to configure heartbeats for your scripts right now? SecuryBlack Heartbeats is available for free during the beta — a curl at the end of your script is all you need.