Linux Administration
Job Scheduling (cron, at)
In this lesson
Job scheduling is the mechanism by which Linux runs commands automatically at specified times — without any human present to trigger them. Scheduled jobs drive virtually every automated task on a production server: database backups, log rotation, security scans, certificate renewals, and report generation. Mastering cron, at, and systemd timers means you can make a server take care of itself reliably around the clock.
cron and the crontab Format
cron is the long-running daemon that executes scheduled commands. Each user's schedule is defined in their crontab (cron table) — a text file with one job per line. Every line follows a strict five-field time specification followed by the command to run. Reading cron syntax fluently is a foundational Linux administration skill.
Fig 1 — Anatomy of a crontab line: five time fields followed by the command
| Expression | Meaning |
|---|---|
0 * * * * |
Every hour, on the hour |
*/15 * * * * |
Every 15 minutes |
0 2 * * * |
Every day at 2:00 AM |
30 2 * * 1 |
Every Monday at 2:30 AM |
0 0 1 * * |
First day of every month at midnight |
0 9-17 * * 1-5 |
Every hour from 9 AM to 5 PM, Monday through Friday |
0 2 * * 0,6 |
2 AM on weekends (Saturday and Sunday) |
@reboot |
Once, at system startup — runs after cron daemon starts |
Analogy: A crontab is like a standing order at a restaurant — "every morning at 7am, bring me coffee and a newspaper". You write the order once, and it executes automatically at the specified time indefinitely. at, by contrast, is a one-time reservation: "bring me a bottle of champagne at 8pm tonight specifically".
Managing Crontabs
Each user maintains their own crontab file stored in /var/spool/cron/crontabs/ — never edit these files directly. Use the crontab command, which validates syntax before saving. System-wide scheduled jobs live in /etc/cron.d/, /etc/cron.daily/, and related directories.
# Open your crontab for editing (uses $EDITOR, defaults to vi)
crontab -e
# List your current crontab
crontab -l
# Remove your entire crontab (use with care — no confirmation prompt)
crontab -r
# View or edit another user's crontab (root only)
sudo crontab -u alice -l
sudo crontab -u alice -e
# Set EDITOR to nano before opening crontab (if you prefer nano)
EDITOR=nano crontab -e# A well-structured crontab — annotated example
# Always set SHELL and PATH explicitly — cron runs with a minimal environment
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=admin@example.com # Send output/errors to this address (or "" to silence)
# Daily database backup at 02:00 — redirect both stdout and stderr to log
0 2 * * * /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1
# Clear temp files every 15 minutes
*/15 * * * * find /tmp -name "*.tmp" -mmin +60 -delete
# Weekly report every Sunday at 06:30
30 6 * * 0 /opt/scripts/weekly-report.sh
# Renew SSL cert check daily at 03:15
15 3 * * * /usr/bin/certbot renew --quiet
# Run a script once at system boot
@reboot /opt/scripts/startup-checks.sh# crontab -l SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin MAILTO=admin@example.com 0 2 * * * /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1 */15 * * * * find /tmp -name "*.tmp" -mmin +60 -delete 30 6 * * 0 /opt/scripts/weekly-report.sh 15 3 * * * /usr/bin/certbot renew --quiet @reboot /opt/scripts/startup-checks.sh
What just happened? Setting SHELL, PATH, and MAILTO at the top of the crontab are defensive practices that solve the three most common reasons cron jobs silently fail: wrong shell, missing PATH causing command-not-found errors, and no visibility into errors because output is discarded. Without 2>&1, stderr from a failing script disappears entirely.
System Cron Directories
Beyond user crontabs, Linux provides a set of system-level directories where scripts are placed to run on standard schedules. This is how package managers register recurring maintenance tasks — simply drop an executable script into the right directory.
Scripts placed here run once per hour. The exact minute is defined by /etc/cron.d/0hourly.
Scripts run once per day. Log rotation (logrotate) is a typical resident here.
Scripts run once per week. Database maintenance scripts are commonly placed here.
Scripts run once per month. Billing reports, monthly archive jobs.
Full crontab-format files with custom schedules. Packages like certbot drop files here.
# List what is scheduled in the system cron directories
ls -la /etc/cron.daily/
ls -la /etc/cron.d/
# Drop a script into cron.daily — it must be executable and have no extension
sudo cp /opt/scripts/cleanup.sh /etc/cron.daily/cleanup
sudo chmod +x /etc/cron.daily/cleanup
# View the main system crontab (defines when the cron.* dirs run)
cat /etc/crontab
# View a package-installed cron entry
cat /etc/cron.d/certbot# cat /etc/crontab SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin 17 * * * * root cd / && run-parts --report /etc/cron.hourly 25 6 * * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily ) 47 6 * * 7 root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly ) 52 6 1 * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly ) # cat /etc/cron.d/certbot 0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && \ perl -e 'sleep int(rand(43200))' && certbot -q renew
What just happened? The /etc/crontab shows cron using run-parts to execute all scripts in each directory. The certbot entry uses rand(43200) to add a random delay up to 12 hours — this is intentional to prevent thousands of servers all hitting Let's Encrypt's API simultaneously at exactly the same second.
One-Time Jobs with at
While cron handles recurring schedules, at schedules a command to run exactly once at a specified future time. It is ideal for one-off maintenance tasks — deploying a change at midnight, sending a notification after a long job, or scheduling a server restart during a maintenance window.
# Schedule a command to run at a specific time (interactive — type command, then Ctrl+D)
at 02:30
# Schedule inline using a heredoc (non-interactive — better for scripts)
at 02:30 <<'EOF'
/opt/scripts/db-maintenance.sh >> /var/log/maintenance.log 2>&1
EOF
# at accepts flexible time expressions
at 'now + 1 hour'
at 'midnight'
at 'noon tomorrow'
at '3pm Friday'
at '23:00 2025-12-31'
# List pending at jobs
atq
# View the contents of a pending job (job number from atq)
at -c 3
# Remove a pending job by job number
atrm 3
# Install at if not present
sudo apt install at -y # Debian/Ubuntu
sudo dnf install at -y # RHEL/Rocky
sudo systemctl enable --now atd# at 'now + 2 hours' <<'EOF' > /opt/scripts/db-maintenance.sh >> /var/log/maintenance.log 2>&1 > EOF job 3 at Wed Mar 12 13:45:00 2025 # atq 3 Wed Mar 12 13:45:00 2025 a alice 5 Thu Mar 13 02:30:00 2025 a root # atrm 3 # (no output — job 3 cancelled) # atq 5 Thu Mar 13 02:30:00 2025 a root
What just happened? at confirmed the job with its assigned number and exact execution time. atq showed two queued jobs from different users — notice each has a queue letter (a) which controls priority. After atrm 3, only the root job remains — the cancelled job is gone from the queue entirely.
systemd Timers — The Modern Alternative
systemd timers are unit files with a .timer extension that activate a paired .service unit on a schedule. They offer key advantages over cron: full integration with the journal for logging, the ability to catch up missed runs after a system sleep or outage, dependency management, and per-job resource limits.
cron — simple, universal
- Available everywhere, no extra setup
- Compact one-line syntax
- Output handling is manual (redirect or MAILTO)
- Missed runs are silently skipped
- No dependency ordering with other services
- Harder to test and inspect individually
systemd timers — powerful, integrated
- All output captured in the journal automatically
- Can catch up missed runs (
Persistent=true) - Can depend on network, mounts, other services
- Resource limits via cgroup integration
- More verbose — two files per scheduled task
systemctl list-timersshows next run time
# /etc/systemd/system/db-backup.service
[Unit]
Description=Database Backup Job
After=network-online.target postgresql.service
[Service]
Type=oneshot
User=backup
ExecStart=/opt/scripts/db-backup.sh
StandardOutput=journal
StandardError=journal# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run database backup daily at 02:00
[Timer]
# Calendar expression — same concept as cron but more readable
OnCalendar=*-*-* 02:00:00
# Run missed jobs on next boot if the system was off at scheduled time
Persistent=true
# Randomise start within a 5-minute window to avoid thundering herd
RandomizedDelaySec=300
[Install]
WantedBy=timers.target# Enable and start the timer (not the service — the timer drives the service)
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer
# List all active timers with their next scheduled run time
systemctl list-timers
# Check when the timer last ran and its status
systemctl status db-backup.timer
# View the output from the last run of the associated service
journalctl -u db-backup.service
# Trigger the service immediately (to test without waiting for the timer)
sudo systemctl start db-backup.service# systemctl list-timers NEXT LEFT LAST PASSED UNIT Wed 2025-03-13 02:00:00 UTC 14h left Wed 2025-03-12 02:00:07 UTC 9h ago db-backup.timer Wed 2025-03-12 12:00:00 UTC 17min Wed 2025-03-12 00:00:08 UTC 11h ago certbot.timer Wed 2025-03-12 12:05:00 UTC 22min Wed 2025-03-12 00:05:01 UTC 11h ago apt-daily.timer # journalctl -u db-backup.service Mar 12 02:00:07 server1 systemd[1]: Starting Database Backup Job... Mar 12 02:00:07 server1 db-backup.sh[9821]: Dumping database: myapp_prod Mar 12 02:00:31 server1 db-backup.sh[9821]: Backup complete: 247MB written Mar 12 02:00:31 server1 systemd[1]: db-backup.service: Deactivated successfully.
What just happened? systemctl list-timers showed every scheduled timer with its exact next run time and how long ago it last ran — information that simply does not exist natively in cron. The journal captured the full script output automatically, including the specific database dumped and the size written, without any manual output redirection in the script.
Debugging Scheduled Jobs
Jobs that work perfectly when run manually often fail silently when run by cron. The reason is almost always environment — cron runs with a stripped-down shell environment that has a minimal PATH, no user profile loaded, and different environment variables. A systematic debugging approach resolves most issues quickly.
Step 1 — Check whether the job ran at all
Look in the system logs for cron execution records. If the job never appeared in the log, the crontab syntax is likely wrong or the cron daemon is not running.
grep CRON /var/log/syslog | tail -20 # Debian/Ubuntu
grep cron /var/log/cron | tail -20 # RHEL/Rocky
Step 2 — Check the cron mail output
If MAILTO is not set, cron delivers output to the local user's mail spool. Check it for error messages.
cat /var/mail/alice
# or
mail
Step 3 — Simulate the cron environment manually
Run the script using the same minimal environment cron uses. This exposes PATH and variable issues immediately.
env -i HOME=/home/alice SHELL=/bin/bash PATH=/usr/bin:/bin bash /opt/scripts/backup.sh
Step 4 — Ensure the script captures its own output
Add explicit logging inside the script itself, and always redirect both stdout and stderr in the crontab entry.
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# Confirm cron daemon is running
systemctl status cron # Debian/Ubuntu
systemctl status crond # RHEL/Rocky
# Check cron's log for recent job executions
grep CRON /var/log/syslog | grep -E "alice|backup" | tail -10
# Test a script with a cron-like minimal environment
env -i HOME=/root SHELL=/bin/bash \
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin \
/opt/scripts/backup.sh
# Schedule a test job to run one minute from now — fastest way to verify cron works
echo "$(date -d '+1 minute' +'%M %H %d %m') * /bin/echo 'cron works' >> /tmp/cron-test.log" | crontab -
# Wait 1 minute, then check:
cat /tmp/cron-test.logAlways Use Full Paths in Crontab Commands
cron runs with a minimal PATH that typically includes only /usr/bin and /bin. A command like backup.sh or even python3 may simply not be found — the job will fail silently if output is not being captured. Always write the full path: /usr/bin/python3, /opt/scripts/backup.sh. Use which python3 in a normal shell to find the full path before writing a crontab entry.
Lesson Checklist
@reboot shorthand
SHELL, PATH, and MAILTO at the top of a crontab and redirect 2>&1 in every job entry
at to schedule one-time jobs and manage the queue with atq and atrm
.service and .timer unit to schedule a task with systemd, and use systemctl list-timers to inspect all scheduled timers
Teacher's Note
The single most reliable way to verify a new cron job works is the one-minute test: write a job scheduled for one minute from now that appends a timestamp to a file, wait for it, and confirm the file was written. This test confirms the cron daemon is running, the crontab syntax is correct, the script is reachable, and output is being captured — all in under two minutes.
Practice Questions
1. Write a complete, production-ready crontab entry that runs /opt/scripts/report.sh at 6:30 AM every weekday (Monday–Friday), logs all output and errors to /var/log/report.log, and explain each component of the entry.
30 6 * * 1-5 /opt/scripts/report.sh >> /var/log/report.log 2>&1 — 30 = minute 30, 6 = 6 AM, * * = every day and month, 1-5 = Monday through Friday. >> appends stdout to the log file; 2>&1 also captures stderr to the same file.
2. A cron job runs python3 /opt/scripts/sync.py every hour but silently does nothing. When you run the same command in your terminal it works perfectly. Describe the most likely cause and exactly how you would diagnose and fix it.
python3 /opt/scripts/sync.py >> /tmp/sync.log 2>&1 in the crontab to capture errors. Also check /var/log/syslog or journalctl -u cron. Fix by using full paths for all commands and any file references inside the script, or set the required environment variables explicitly at the top of the crontab.
3. Explain two specific advantages systemd timers have over cron that make them worth the extra verbosity for production workloads. In what scenario would you still prefer cron over a systemd timer?
journalctl -u servicename, unlike cron which only emails output. (2) Persistent=true catches missed executions after downtime. Prefer cron when: the system may not use systemd (older distros, containers), when the schedule is simple and already in a well-maintained crontab, or when the job is user-level rather than system-level.
Lesson Quiz
1. What does the crontab expression */10 6-18 * * 1-5 mean?
2. What is the purpose of setting Persistent=true in a systemd timer unit?
3. You need to schedule a one-time server maintenance script to run at 2 AM tomorrow. Which tool is most appropriate and what command would you use?
Up Next
Lesson 17 — Environment Variables
Understanding, setting, and persisting environment variables across sessions and services