That's the thing about cron failures: they don't crash, they don't alert, they just don't happen. And you find out hours (or days) later when someone notices the data didn't update or the emails didn't go out.

Here are the five mistakes that cause this. All real, all fixable.

First, the one idea that explains all five: cron does not run in your shell.

The environment cron actually runs in vs what you picture in your head


1. The timezone trap

Your crontab line says 0 9 * * *. You expect it to fire at 9am. It fires at 7pm instead.

Cron fires this job when the daemon's clock reads 09:00. On a UTC server, that's 09:00 UTC. In UTC+10, that's 7pm your time, the same day. You wanted 9am local, you got 7pm local: off by exactly your UTC offset. Flip the sign and you'll know which way it slips.

First: check your daemon's actual timezone.

timedatectl
      # or just:
      date
      

Then set CRON_TZ explicitly at the top of your crontab:

CRON_TZ=America/New_York
      0 9 * * * /path/to/job.sh
      

The most portable approach is to schedule everything in UTC deliberately and convert mentally. UTC doesn't shift for daylight savings. Your jobs fire predictably.


2. The bare PATH problem

Your script runs perfectly from the terminal. In cron, it fails silently with command not found.

Cron starts with a stripped-down environment. No .bashrc, no .profile, nothing sourced. The default PATH is usually just /usr/bin:/bin. That means:

  • node via nvm: not there
  • python3 from a virtualenv: not there
  • Custom binaries in ~/bin: not there
  • Any tool that depends on a configured PATH: broken

The fix is explicit exports at the top of every script cron runs:

#!/bin/bash
      export NVM_DIR="$HOME/.nvm"
      [ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"
      export PATH=/home/youruser/.nvm/versions/node/v20.0.0/bin:/usr/local/bin:/usr/bin:/bin
      

Or use full absolute paths in the crontab itself:

0 9 * * * /home/youruser/.nvm/versions/node/v20.0.0/bin/node /path/to/script.js
      

The absolute-path approach is brittle when you update Node. The export at the top of the script is cleaner for anything non-trivial. Pick one, be consistent.


3. No year field means one-shots repeat forever

You want to run a migration once, on a specific date. You add:

0 10 4 7 * /path/to/migration.sh
      

It runs on July 4th. Then again next year. And the year after.

Crontab has no year field. The five columns are: minute, hour, day-of-month, month, day-of-week. There's no way to express "only 2026." Everything in a crontab is a recurring schedule.

For genuine one-shots, use systemd-run:

systemd-run --user \
        --on-calendar="2026-07-04 10:00:00" \
        --unit=migration-july4 \
        /path/to/migration.sh
      

Fires once, never repeats. The transient unit will still appear in systemctl --user list-timers until the session restarts or you run systemctl --user reset-failed migration-july4 -- harmless, but don't be surprised to see it listed.

If systemd isn't available, the at command works:

echo "/path/to/migration.sh" | at 10:00 Jul 4
      

at isn't installed on a lot of minimal server images (apt install at / apk add at first), and month parsing is finicky -- Jul is safer than July. The fallback of "have the script delete its own crontab line" also works but is fragile. Prefer systemd-run or at for anything that should only run once.


4. Silent failure, no one watching

A cron job runs. It exits with code 1. Nothing happens. No alert, no log, no indication anything went wrong.

By default, cron sends stdout and stderr to a local mail spool at /var/spool/mail/youruser. Nobody reads that. If sendmail isn't configured, output goes nowhere.

Your job can fail every single run for a week and you'd never know.

Minimum viable fix: redirect output to a log.

0 9 * * * /path/to/job.sh >> /var/log/myjob.log 2>&1
      

Add timestamps so you can tell when each run happened:

#!/bin/bash
      echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting..."
      # ... your work ...
      echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done."
      

For anything that actually matters, add a dead-man's switch. Healthchecks.io is free for small setups. Your job pings a URL on success. If no ping arrives in the expected window, you get alerted.

#!/bin/bash
      if /path/to/actual-work.sh; then
        curl -fsS --retry 3 https://hc-ping.com/your-uuid > /dev/null
      fi
      

The difference between "job dead for 24 hours before we noticed" and "paged within 5 minutes" is that one curl call at the end.


5. Overlapping runs

Your job is scheduled every 5 minutes. Runs take 2 minutes, usually. But occasionally a database is slow and it takes 7. Now a second copy starts on top of the first.

Two instances processing the same queue, writing to the same table, sending the same emails.

The fix is a lockfile. An atomic mkdir is the most portable pattern:

#!/bin/bash
      LOCK_DIR="/tmp/myjob.lock"

      # Try to acquire the lock
      if ! mkdir "$LOCK_DIR" 2>/dev/null; then
        echo "[$(date)] Already running, exiting." >&2
        exit 1
      fi

      # Release on exit, including crashes
      trap "rmdir '$LOCK_DIR'" EXIT

      # ... your actual job logic ...
      

The trap on EXIT is critical. If your script crashes mid-run, the lock gets cleaned up automatically. Without it, the lock directory persists and the job never runs again until someone manually deletes it.

If you know flock is available:

#!/bin/bash
      exec 200>/tmp/myjob.flock
      flock -n 200 || { echo "already running"; exit 1; }

      # ... your job logic ...
      

Either works. The mkdir approach is more portable; flock is marginally cleaner.


Why these all share the same root cause

Most of these failures share a single root: cron doesn't run in your shell environment. It runs in a bare, minimal execution context with no profile, a stripped PATH, a daemon timezone, and output going nowhere. The gap between what runs on your terminal and what runs in cron is where all of this lives.

Before adding any new cron job, run through this:

cron pre-flight checklist: timezone, PATH, one-shot, logging, lockfile

  • [ ] Timezone set explicitly (CRON_TZ= or scheduled in UTC)
  • [ ] Full PATH exported at the top of the script
  • [ ] One-shot? Use systemd-run or at, not a crontab line
  • [ ] Output redirected to a log file with >> job.log 2>&1
  • [ ] Heartbeat ping for anything production-critical
  • [ ] Lockfile guard if the job runs frequently or takes variable time

Cron is simple enough that none of these traps are obvious until you've hit them. Now you have the list before that happens.


I write these from real work at astraedus.dev, that's where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].