Cron Jobs on VPS: Scheduling, Logging, Debugging, and Best Practices for Reliability
Cron is the standard Linux task scheduler — it runs commands at specified times without human intervention. Cron jobs that silently fail are one of the most common VPS administration headaches: the task appears configured but either runs incorrectly or doesn’t run at all. This guide covers setup, debugging, logging, and reliability best practices.
Crontab Syntax Reference
# ┌──────────── minute (0–59)
# │ ┌────────── hour (0–23)
# │ │ ┌──────── day of month (1–31)
# │ │ │ ┌────── month (1–12)
# │ │ │ │ ┌──── day of week (0–6, Sunday=0)
# * * * * * command_to_run
# Common examples:
0 3 * * * # Every day at 3:00 AM
*/15 * * * * # Every 15 minutes
0 */6 * * * # Every 6 hours
0 9 * * 1-5 # Weekdays at 9:00 AM
0 0 1 * * # First day of every month at midnight
@reboot # Once on system startup
Managing Crontabs
crontab -e # Edit current user's crontab
crontab -l # List current user's crontab
crontab -r # Remove all crontab entries (use carefully!)
sudo crontab -u www-data -e # Edit another user's crontab
ls /etc/cron.d/ # Per-application system cron files
ls /etc/cron.daily/ # Scripts run daily by the system
The #1 Cron Failure: PATH Differences
Cron runs with a minimal environment and does not source your ~/.bashrc, ~/.profile, or shell startup files. Programs in your PATH when using SSH may not be in cron’s PATH.
# BAD: relies on user's PATH — will silently fail in cron
0 3 * * * node /var/www/myapp/scripts/backup.js
# GOOD: use absolute paths
0 3 * * * /usr/local/bin/node /var/www/myapp/scripts/backup.js
# Find the full path to any command:
which node # → /usr/local/bin/node
which python3 # → /usr/bin/python3
which wp # → /usr/local/bin/wp
# Set PATH explicitly at the top of crontab
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
HOME=/root
Capture All Output to Log Files
# Append stdout and stderr to log file (keeps history)
0 3 * * * /usr/local/bin/node /var/www/myapp/backup.js >> /var/log/backup.log 2>&1
# Include timestamp in each log entry
0 3 * * * echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup" >> /var/log/backup.log \
&& /usr/local/bin/node /var/www/myapp/backup.js >> /var/log/backup.log 2>&1
# Suppress all output (silent mode)
0 3 * * * /usr/local/bin/node /var/www/myapp/backup.js > /dev/null 2>&1
Debugging Failing Cron Jobs
# Step 1: Check cron daemon is running
sudo systemctl status cron
# Step 2: Check cron logs
sudo grep CRON /var/log/syslog | tail -50
sudo journalctl -u cron --since "1 hour ago"
# Step 3: Verify the job actually ran
sudo grep "backup" /var/log/syslog
# Step 4: Simulate cron's minimal environment (key debugging step!)
env -i HOME=/home/deploy USER=deploy PATH=/usr/local/bin:/usr/bin:/bin \
/bin/bash -c "your_command_here"
# If this fails but your normal shell works → it's a PATH issue
Wrapper Scripts for Reliability
sudo nano /usr/local/bin/backup-wrapper.sh
#!/bin/bash
set -euo pipefail # Exit on any error, undefined variable, or pipe failure
LOGFILE="/var/log/backup.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TIMESTAMP] Backup started" >> "$LOGFILE"
export PATH="/usr/local/bin:/usr/bin:/bin"
if /usr/local/bin/node /var/www/myapp/backup.js >> "$LOGFILE" 2>&1; then
echo "[$TIMESTAMP] Backup completed successfully" >> "$LOGFILE"
else
EXIT_CODE=$?
echo "[$TIMESTAMP] ERROR: Backup failed (exit code $EXIT_CODE)" >> "$LOGFILE"
exit 1
fi
chmod +x /usr/local/bin/backup-wrapper.sh
# Clean, readable crontab entry:
0 3 * * * /usr/local/bin/backup-wrapper.sh
Prevent Overlapping Runs with flock
If a cron job takes longer than its interval, multiple instances run simultaneously and conflict. flock prevents this:
# flock exits immediately if lock is already held (-n = non-blocking)
*/15 * * * * flock -n /tmp/mybackup.lock /usr/local/bin/backup-wrapper.sh
Log Rotation for Cron Log Files
sudo nano /etc/logrotate.d/cron-logs
/var/log/backup.log {
weekly
rotate 4
compress
missingok
notifempty
}
systemd Timers: The Modern Alternative
systemd timers have dependency management, can catch up on missed runs after downtime, and log to journald:
# /etc/systemd/system/mybackup.service
[Unit]
Description=My Application Backup
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-wrapper.sh
User=deploy
# /etc/systemd/system/mybackup.timer
[Unit]
Description=Run mybackup daily at 3 AM
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true # Runs if missed due to downtime
AccuracySec=1min
[Install]
WantedBy=timers.target
sudo systemctl enable --now mybackup.timer
sudo systemctl list-timers # View all active timers
Monitor with Healthchecks.io
Healthchecks.io alerts you when a cron job doesn’t run — the most dangerous silent failure mode. Free for up to 20 checks:
0 3 * * * curl -fsS https://hc-ping.com/YOUR-UUID/start > /dev/null; \
/usr/local/bin/backup-wrapper.sh \
&& curl -fsS https://hc-ping.com/YOUR-UUID > /dev/null \
|| curl -fsS https://hc-ping.com/YOUR-UUID/fail > /dev/null
Getting Started
Cron is available on all Ubuntu VPS plans at VPS.DO and requires no additional installation. The most impactful reliability improvements: always use absolute paths, redirect output to log files, and monitor with Healthchecks.io. For complex scheduling with dependencies or catch-up behavior, systemd timers are the better choice.
Conclusion
Reliable cron jobs require four things: absolute paths (not relying on user PATH), output redirection to logs (no silent failures), flock for overlap prevention, and external monitoring for missed schedule detection. With these in place, scheduled tasks run predictably and any failure is immediately visible — rather than going undetected until you notice the backup hasn’t run in three weeks.