Skip to content
DevOps2026-09-235 min read

Linux Crontab in Practice: Commands, Logs, and Debugging

crontab is a program, not just a syntax

Most tutorials present crontab as "a five-field expression", but the first time a job silently fails on a production box you learn the truth: the expression is the easy half. crontab is the management entry point for the cron daemon — it decides whether your job runs, as whom, and where the failure evidence lands.

A full crontab entry looks like this:

30 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

The first five fields are the schedule rule (minute hour day month weekday); the rest is the command itself. For writing rules, point-and-click with the online Cron expression generator and reverse-check the human-readable meaning with the parser — this post covers the other half: installing, listing, and debugging.

Three commands you must know, and one dangerous button

crontab -e    # edit the current user's jobs
crontab -l    # list the current user's jobs
crontab -r    # remove ALL jobs of the current user (no confirmation!)

-r sits right next to -e on the keyboard; a slipped finger silently wipes every job — a classic ops incident. Two defenses:

  1. Back up before editing: crontab -l > ~/crontab.bak.$(date +%F)
  2. To retire one job, always comment the line out via crontab -e (leading #), never rebuild with -r.

Saving from crontab -e hot-reloads on most distributions — no service restart needed. Editing the files under /var/spool/cron/ directly does not trigger a reload, so never bypass the crontab command.

User crontab vs /etc/crontab vs /etc/cron.d

| Location | Format | Best for | |---|---|---| | crontab -e (/var/spool/cron/) | 5 fields, runs as the owning user | personal / app-account jobs | | /etc/crontab | 6 fields — extra "user" field | the traditional system-wide file | | /etc/cron.d/xxx | same 6 fields as /etc/crontab | recommended for packages and ops scripts — one file per job group |

File names under /etc/cron.d/ may only contain letters, digits, underscores, and hyphens — a dotted name like backup.sh is silently ignored by cron, the number-one cause of "the file is there but nothing runs". The file must also be mode 0644 and owned by root, or it is rejected.

@reboot and friends: seven shortcuts

@reboot /opt/scripts/init-cache.sh
@daily  /opt/scripts/cleanup.sh

Full list: @reboot (at boot), @yearly/@annually, @monthly, @weekly, @daily/@midnight, @hourly. They work inside /etc/cron.d/ too. @reboot is handy for rebuilding caches, re-mounting, or starting local services after a cloud VM restart — far lighter than a systemd unit.

The job never ran: a five-step debug path

Walk it in order; most cases die in the first three steps:

  1. Is the cron daemon alive? systemctl status crond (CentOS) or systemctl status cron (Debian/Ubuntu).
  2. Read cron's own log: grep CRON /var/log/cron (CentOS) or /var/log/syslog (Ubuntu). An execution record means scheduling is fine and the script is the suspect; no record at all means the break is at the scheduling layer.
  3. Run the command manually: paste the exact command from the entry into a shell. The cron environment differs from your interactive shell (next step) — this is the root of "works when I run it, not from cron".
  4. Check PATH: cron jobs typically get only /usr/bin:/bin. Binaries installed under /usr/local/binpython3, node, docker — silently vanish. Fix: use absolute paths inside scripts, or declare PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin at the top of the crontab.
  5. Check output redirection: without redirection, output goes to local mail (silently dropped on many servers). While debugging, always append >> /path/log 2>&1.

The timezone trap: cron follows the system clock

0 3 * * * on a TZ=UTC cloud instance fires at 3 AM UTC — 11 AM in Beijing, not 3 AM local. Three checks:

timedatectl                    # current server timezone
date                           # what the server thinks "now" is
ls -l /etc/localtime           # where the timezone link points

For distributed teams, convert once with a timezone converter before writing the entry, and put the agreed interpretation in a comment next to the job. Docker containers default to UTC and usually ship without a cron daemon — schedule container jobs from the host via docker exec, or move to systemd timers / an in-app scheduler.

Second-level scheduling: cron cannot, do not force it

Standard cron's finest grain is one minute. The folk remedy for every 30 seconds (duplicate the line, add sleep 30) works until the job itself takes longer than 30 seconds and runs stack up. When you truly need seconds:

  • Loop + sleep: while true; do ...; sleep 30; done inside the script, supervised by systemd or supervisor — the most transparent option.
  • systemd timer: OnUnitActiveSec=30s gives native second-level scheduling plus journald logs and dependency management.

One more habit worth forming: before shipping a schedule, translate it back to natural language with the expression parser. It intercepts an entire class of "I thought it ran daily, it actually ran on the 3rd of each month" incidents.

Cheat sheet

| Requirement | Expression | |---|---| | Daily at 3:30 AM | 30 3 * * * | | Mondays at 9:00 AM | 0 9 * * 1 | | 1st of the month, midnight | 0 0 1 * * | | Every 5 minutes | */5 * * * * | | Weekdays at 18:00 | 0 18 * * 1-5 | | At boot | @reboot |

Parse the rule before it ships, back up with -l before editing, and start every investigation in /var/log/cron. Those three sentences dodge nine out of ten crontab pitfalls.