Cron is 50 years old, has five fields, and still owns a special place in every on-call rotation. Nearly every "why did this run at 3am" ticket traces back to one of four things.
1. Two restricted date fields mean OR, not AND
0 9 1 * 1 reads like "9:00 on the 1st, if it's a Monday." Cron reads it as "9:00 on the 1st or any Monday." When both day-of-month and day-of-week are restricted, you get a union — so a monthly job quietly also runs four Mondays a month.
Fix: keep one field as * and filter inside the command.
# Mondays only
0 9 * * 1 /app/monthly.sh
If you specifically need "the Monday that falls in the first week," schedule every Monday and let the script check date +%d.
2. */2 in day-of-month doesn't mean "every two days"
Steps in the day-of-month field start at 1, so 0 3 */2 * * fires on 1, 3, 5 … 31 and then resets. The gap across the month boundary is one day after a 31-day month and two days after a 30-day month. Any job doing interval arithmetic — log rotation, rolling windows, invoice cutoffs — drifts or double-fires.
Fix: run daily and store the last-run date in a file, or use a scheduler with real interval semantics.
3. Cron uses the server's timezone, and containers are UTC
0 0 * * * means midnight on the host. Inside a container that is midnight UTC: 8am in Beijing, 5pm the previous day in California. GitHub Actions cron is always UTC regardless of what your repo implies.
Fix: be explicit. CRON_TZ=America/New_York (cronie), or set TZ on the container, or keep everything UTC on purpose and convert in the application. All three beat "we assume it's local time."
4. An unescaped % eats the rest of your command
In a crontab file, % is not a literal character — it terminates the command and everything after it becomes the job's stdin:
0 4 * * * /usr/bin/backup.sh --name daily-%Y%m%d
That runs backup.sh --name daily- with Y%m%d piped into it, and still exits 0. Escape each percent sign: daily-\%Y\%m\%d. GitHub Actions and Kubernetes treat % literally, so this trap is crontab-only — which makes it harder to catch when you move a workflow between the two.
How I check a schedule before it ships
I paste the expression into a visual builder that renders the next five run times in plain English — CodeToolbox's cron generator does it locally in the browser with no signup, and shows the day-of-month/day-of-week OR behaviour explicitly — then I compare those timestamps against my own calendar before the expression reaches a server. Five run times surface all four traps above.
What's the worst cron bug you've had to debug?
Top comments (0)