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:
- Back up before editing:
crontab -l > ~/crontab.bak.$(date +%F) - 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:
- Is the cron daemon alive?
systemctl status crond(CentOS) orsystemctl status cron(Debian/Ubuntu). - 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. - 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".
- 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 declarePATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binat the top of the crontab. - 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; doneinside the script, supervised by systemd or supervisor — the most transparent option. - systemd timer:
OnUnitActiveSec=30sgives 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
| 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.
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'.
Related Tools
Related Articles
docker run to docker-compose: The Complete Migration Guide (with Flag Map)
Migrate docker run commands to docker-compose.yml: five advantages of compose over raw run, a flag-by-flag mapping table (ports, volumes, env, restart, networks), the classic pitfalls (bare -e passthrough, anonymous volumes, --rm semantics), and an online converter that runs locally in your browser.
Permission denied (publickey): 6 Reasons Git Push Fails Over SSH (and the Fix for Each)
git clone or push rejected with Permission denied (publickey) fatal: Could not read from remote repository? Covers missing keygen, key not loaded in the agent, public key not added to GitHub/GitLab, multi-account key routing with ~/.ssh/config, deploy key limits, and wrong remote URLs — with ssh -v diagnostics.
npm ERR! ERESOLVE: peer dependency conflicts — the complete fix guide
npm install fails with ERESOLVE peer dependency conflicts? Understand what peer dependencies are, why npm 7+ enforces them, and 4 resolution strategies (force, legacy-peer-deps, overrides, manual) with trade-offs.