Skip to content
DevOps2026-09-236 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

LocationFormatBest for
crontab -e (/var/spool/cron/)5 fields, runs as the owning userpersonal / app-account jobs
/etc/crontab6 fields — extra "user" fieldthe traditional system-wide file
/etc/cron.d/xxxsame 6 fields as /etc/crontabrecommended 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/bin — python3, 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.

Percent signs %: the invisible newline in crontab

Unescaped % in a crontab command body is treated as a newline: everything before % is the command; everything after is fed to that command on stdin. This is a common reason a script “works by hand” and “breaks in crontab” — especially date +%Y%m%d.

# Wrong: %Y becomes a newline separator
0 2 * * * /opt/backup.sh /backups/db_$(date +%Y%m%d).sql.gz

# Right: escape every %
0 2 * * * /opt/backup.sh /backups/db_$(date +\%Y\%m\%d).sql.gz

Safer still: build dates inside the script and keep the crontab line as a short script path. The generator only emits the five-field schedule — it does not escape % inside your command body.

MAILTO and silent failure

Without redirection, cron mails stdout/stderr locally. Many cloud images ship no MTA, so mail vanishes and the job “does nothing.” During debugging, do both:

MAILTO=""
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

MAILTO="" disables mail; logs land in a rotated file. If you later want mail alerts, set MAILTO=ops@example.com and confirm sendmail/postfix can actually deliver.

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.

Align with the parser before go-live

After writing a rule, use the expression parser twice: read field expansion and the summary so you do not ship “daily” when you meant “3rd of month”; then check Next 5 runs (browser local timezone). If the server is UTC and you are in UTC+8, convert the preview or add a temporary probe on the box:

*/5 * * * * date >> /tmp/cron-smoke.log 2>&1

Watch a few real fires, then delete the probe. When both day-of-month and day-of-week are restricted, the parser lists fires under OR semantics — the fastest way to catch a misread of 0 0 1 * 1. Field details: Cron expressions explained.

Cheat sheet

RequirementExpression
Daily at 3:30 AM30 3 * * *
Mondays at 9:00 AM0 9 * * 1
1st of the month, midnight0 0 1 * *
Every 5 minutes*/5 * * * *
Weekdays at 18:000 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.

Triage notes

The opening draws the boundary: the expression is only the entry ticket, and this guide focuses on the other half — installing, listing, and debugging. The dangerous -r button gets a separate warning because it sits next to -e and one slip silently wipes every task; the defense is concrete: to remove a single job, always comment out that line under -e. The five-step debug path says plainly that most problems surface in the first three steps, and it spells out the intent of step 3, running the command by hand: the cron environment is not an interactive shell, which is the root of 'it runs when I try it and never under cron'; the rule about dotted filenames being ignored in /etc/cron.d is flagged as the number-one cause of 'the files are all there but nothing runs'.