RHEL Intermediate #6: Job Scheduling — cron, systemd timer, at
In operations, time-based tasks come up constantly: daily early-morning backups, weekly report emails, per-minute health checks, a script you need to run just once in an hour. RHEL 9 splits these needs across four tools — cron, anacron, at, and systemd timer. The core question this post answers is which one to use when.
The position of this post in the RHEL Intermediate series:
- #1 Intro to SELinux — Enforcing/Permissive, labels, troubleshooting
- #2 LVM — PV/VG/LV, snapshots, expansion
- #3 Advanced storage — Stratis, NFS, Samba
- #4 Networking — NetworkManager (nmcli), bonding, teaming
- #5 Log management — journald, rsyslog, log rotation
- #6 Job scheduling — cron, systemd timer, at ← this post
- #7 Intro to containers — Podman/Buildah/Skopeo (differences from Docker)
The four tools in one line #
| Tool | Use | Time machine was off | Notes |
|---|---|---|---|
| cron | repeated tasks (every minute/hour/day/…) | just skipped | most traditional, simplest |
| anacron | day/week/month execution guaranteed even if machine was off and back on | catches up missed jobs | essential for laptops / home servers |
| at | one-shot scheduling (run once an hour later) | runs immediately after machine is on | one-time |
| systemd timer | repetition + dependencies + log integration | catches up with Persistent=true | modern standard |
The flow when grabbing a new task:
- repeat + simple → cron
- repeat + uncertain whether machine will be on → anacron or systemd timer (
Persistent=true) - repeat + dependencies / journald integration needed → systemd timer
- just once → at
cron — the traditional starting point #
The cronie package is installed by default in RHEL 9, and the crond service is always running.
$ systemctl status crond
● crond.service - Command Scheduler
Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled; ...)
Active: active (running)Files cron looks at, in two branches:
/etc/crontab ← system-wide (convention)
/etc/cron.d/<name> ← system-wide (drop-in, recommended)
/etc/cron.{hourly,daily,weekly,monthly}/ ← scripts placed in directories run automatically
/var/spool/cron/<user> ← per-user crontab (don't edit directly ✗, use crontab -e)crontab syntax #
Five columns of time + command. Dizzying at first sight but becomes second nature with frequent use.
* * * * * command
│ │ │ │ │
│ │ │ │ └─ day of week (0-7, both 0 and 7 are Sunday)
│ │ │ └─── month (1-12)
│ │ └───── day (1-31)
│ └─────── hour (0-23)
└───────── minute (0-59)Frequently used patterns:
# every minute
* * * * * /usr/local/bin/healthcheck.sh
# 3:15 every morning
15 3 * * * /usr/local/bin/backup.sh
# weekdays (Mon~Fri) 9 AM
0 9 * * 1-5 /usr/local/bin/morning-report.sh
# every 5 minutes
*/5 * * * * /usr/local/bin/ping.sh
# 9-18 hours, every 30 minutes
0,30 9-18 * * * /usr/local/bin/business-hours.sh
# midnight on the 1st of every month
0 0 1 * * /usr/local/bin/monthly-cleanup.sh
# shorthand expressions
@reboot /usr/local/bin/on-startup.sh
@hourly /usr/local/bin/each-hour.sh
@daily /usr/local/bin/each-day.sh
@weekly /usr/local/bin/each-week.sh
@monthly /usr/local/bin/each-month.sh
@yearly /usr/local/bin/each-year.shUser crontab #
Each user can register their own tasks.
# register/edit (opens with editor)
$ crontab -e
# view
$ crontab -l
# clear
$ crontab -r
# another user's (root only)
$ sudo crontab -u alice -lWhy you must use crontab -e: opening /var/spool/cron/<user> directly in vim means cron never notices the change. crontab -e signals cron automatically after you save.
System crontab vs user crontab #
/etc/crontab and /etc/cron.d/* have one more user column.
# minute hour day month dow user command
*/10 * * * * root /usr/local/bin/sync.shOperational recommendation:
- user task →
crontab -e - package/system task →
/etc/cron.d/<name>(drop-in file) - avoid editing
/etc/crontabdirectly (by convention system-only, packages may overwrite)
cron environment variables #
When cron runs a job, the shell is not a login shell. $PATH is minimal and $HOME is restricted. This is the most common reason a script that works fine manually fails silently inside cron.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=admin@example.com
15 3 * * * root /usr/local/bin/backup.shIf MAILTO= is set to a non-empty value, cron emails the job’s stdout/stderr. In environments where mail delivery is blocked, redirect output to a log file directly inside the script.
15 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1Permission control #
/etc/cron.allow and /etc/cron.deny control per-user cron usage.
- if
cron.allowexists → only users listed there can use it - if
cron.allowdoesn’t exist andcron.denyexists → only users not listed there can use it - if neither exists (RHEL 9 default) → only root can use it
In operations, the whitelist approach using cron.allow is general.
anacron — guaranteed even if machine was off and back on #
cron’s weakness: if the machine is off at 3 AM, the 3 AM backup is simply skipped — you wait until the next day. That is a serious problem on laptops, home servers, and machines that are not always on.
anacron records when each task last ran to disk. Every time the machine boots, it checks whether the specified period has elapsed since the last run, and if so, runs the task immediately.
SHELL=/bin/sh
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root
RANDOM_DELAY=45
START_HOURS_RANGE=3-22
# period delay(min) job-id command
1 5 cron.daily nice run-parts /etc/cron.daily
7 25 cron.weekly nice run-parts /etc/cron.weekly
@monthly 45 cron.monthly nice run-parts /etc/cron.monthlyHow to read:
1 5 cron.daily ...— every day (period 1), 5 minutes after machine is on, run all scripts in/etc/cron.daily/*7 25 ...— every week (period 7)RANDOM_DELAY=45— random delay 0~45 min for load distribution
Behavior in RHEL 9: anacron comes with the cronie package and runs via systemd timer (anacron.timer). So just placing scripts in /etc/cron.daily/* makes cron + anacron work together so they catch up after the next boot even when the machine was off.
Server-class machines run 24/7, so anacron matters less in that context. Still, to prevent a job running twice unexpectedly, always make sure your cron.daily tasks are idempotent.
at — schedule just once #
One-shot schedules like “this command just once 30 minutes later” are at’s territory.
# package (RHEL 9 not installed by default ✗ → install directly)
$ sudo dnf install -y at
$ sudo systemctl enable --now atd
# 30 minutes later
$ at now + 30 minutes
at> /usr/local/bin/restart-app.sh
at> <Ctrl-D>
# specific time
$ at 23:30
$ at 23:30 today
$ at 9am tomorrow
$ at 11:00 2026-05-01
# view queue
$ atq
# view queue contents (by job number)
$ at -c 3
# cancel
$ atrm 3at also has permission control files like cron: /etc/at.allow, /etc/at.deny.
at also provides a variant called batch, which runs the job only when the system load is low (load average below 1.5).
$ batch
at> /usr/local/bin/heavy-job.sh
at> <Ctrl-D>Useful when you want to run heavy tasks like nighttime backups only when the system is idle.
systemd timer — modern standard #
Where cron is a tool from 1975, systemd timer is its modern alternative from the 2010s. RHEL 9 packages are steadily moving from cron to timer. logrotate, dnf-makecache, fstrim — all run via timers now.
Why timer is better than cron #
- journald integration — all output is auto-recorded to journald (cron uses mail or direct redirect)
- dependencies — systemd dependencies like
After=network-online.targetused as is - resource control — service unit’s control options like
MemoryMax=,CPUQuota=as is Persistent=true— catches up the time the machine was off, like anacronOnCalendar=— wider expressiveness than cron and human-readable- trigger validation — preview the next execution time with
systemd-analyze calendar
timer + service pair #
systemd timer always pairs two units. .service (what to actually do) + .timer (when to run).
[Unit]
Description=Daily backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=backup
Group=backup
Nice=10[Unit]
Description=Run daily backup at 03:15
Requires=backup.service
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15min
Persistent=true
Unit=backup.service
[Install]
WantedBy=timers.targetFor activation, only enable the timer (the timer triggers the service).
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now backup.timer
$ systemctl list-timers --allOnCalendar syntax #
Compared to cron’s * * * * *, systemd is much clearer.
# every minute
*-*-* *:*:00
# every day 03:15
*-*-* 03:15:00
03:15 # shorthand
# weekdays 09:00
Mon..Fri *-*-* 09:00:00
Mon..Fri 09:00 # shorthand
# every 5 minutes
*-*-* *:00/5:00
*:0/5 # shorthand
# 1st of every month
*-*-01 00:00:00
monthly # alias
# aliases
hourly / daily / weekly / monthly / yearlySyntax validation and next execution time preview:
$ systemd-analyze calendar 'Mon..Fri 09:00'
Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-04-27 09:00:00 KST
From now: 4 days leftGetting into the habit of confirming the next run time before deploying a rule prevents a lot of operational accidents.
Types of timer triggers #
Besides OnCalendar there are several triggers.
| Trigger | Meaning |
|---|---|
OnCalendar= | calendar time (cron-like absolute time) |
OnBootSec= | N seconds after boot |
OnStartupSec= | N seconds after systemd starts |
OnUnitActiveSec= | N seconds after the service was last activated |
OnUnitInactiveSec= | N seconds after the service ended |
For example, OnUnitActiveSec=10min is “10 minutes after previous run” — similar to cron’s */10 * * * * but based on previous execution time, so if a task drags long the next execution naturally pushes back. cron just runs twice.
Persistent — absorbs anacron’s role #
[Timer]
OnCalendar=daily
Persistent=truePersistent=true means “record the last execution time to disk, and if the time was missed because the machine was off, catch up after boot”. timer absorbs what anacron did.
User timer #
It’s also possible to run only your own tasks via timer without root permissions.
$ mkdir -p ~/.config/systemd/user/
# write ~/.config/systemd/user/myjob.service, myjob.timer
$ systemctl --user daemon-reload
$ systemctl --user enable --now myjob.timer
$ systemctl --user list-timersFor tasks to keep running after the user session ends, enable lingering with loginctl enable-linger <user>.
Commonly seen system timers #
$ systemctl list-timers
NEXT LEFT LAST ... UNIT
Wed 2026-04-23 04:00:00 KST 5h 12min left Tue 2026-04-22 04:00:00 KST ... dnf-makecache.timer
Wed 2026-04-23 06:30:00 KST 7h 42min left Tue 2026-04-22 06:30:00 KST ... logrotate.timer
Wed 2026-04-23 ... ... fstrim.timer
...dnf metadata refresh, logrotate, fstrim, anacron — even with just RHEL 9 default install, multiple timers are already running.
cron vs systemd timer — which one? #
| Criterion | cron favorable | timer favorable |
|---|---|---|
| simple “every minute/day” | ✓ | |
| shell one-liner tasks | ✓ | |
| output integration to journald | ✓ | |
| network/mount dependencies | ✓ | |
| resource limits (CPU/memory) | ✓ | |
| package standard tool | ✓ (RHEL 9 trend) | |
| learning curve | ✓ (5 minutes is enough) |
Operational recommendation:
- if existing tasks run on cron and are simple, leave them
- write newly added system tasks as timers
- introduce timer early if journald integration and dependency control are needed
Debugging — when tasks don’t run #
When cron tasks don’t run #
# 1. is crond alive
$ systemctl status crond
# 2. is the task registered
$ crontab -l # user
$ sudo cat /etc/cron.d/* # system
# 3. cron's own log
$ sudo journalctl -u crond --since "1 hour ago"
# 4. check where the task's stdout/stderr is sent
# (if MAILTO is empty, no mail comes; without redirect, output disappears)
# 5. PATH issue — if manual works but only cron fails, 99% this
# specify PATH= inside crontab or use absolute path in commandWhen systemd timer tasks don’t run #
# 1. is the timer active
$ systemctl status backup.timer
$ systemctl list-timers backup.timer
# 2. is the next scheduled execution at the intended time
$ systemd-analyze calendar 'OnCalendar expression'
# 3. what happened when the service actually ran
$ journalctl -u backup.service --since "1 day ago"
# 4. manually trigger once
$ sudo systemctl start backup.service
# 5. timer's own log
$ journalctl -u backup.timerA manual trigger is the fastest way to separate “is it a timer problem or a problem with the service itself”.
Common traps #
- cron + environment variables: forgetting that the manual shell and cron shell differ makes debugging long. Absolute path +
PATH=inside crontab is safest. - timezone: cron uses the system timezone (
/etc/localtime). If you schedule a KST-based task on a UTC host inside a container, the timing will be off by 9 hours. - DST (daylight saving): KST does not observe DST, but machines in other regions can misbehave twice a year.
OnCalendarhas explicit DST transition handling, making it safer than cron in those environments. - if you enable
Persistent=trueon a timer, bringing the machine back up after a few days offline will trigger all missed runs at once. If running multiple backups simultaneously is a problem, use locking (flock) or make the task idempotent. - at jobs are lost if atd is not running. Verify with
systemctl enable --now atd.
Commands to remember #
| Task | Command |
|---|---|
| edit user crontab | crontab -e |
| view user crontab | crontab -l |
| view all timers | systemctl list-timers --all |
| check timer’s next execution time | systemd-analyze calendar '<expr>' |
| view at queue | atq |
| cancel at task | atrm <id> |
| view cron logs | journalctl -u crond |
| timer’s paired service log | journalctl -u <name>.service |
| manual trigger | systemctl start <name>.service |
Wrapping up #
- cron — traditional tool for simple repeated tasks. User tasks via
crontab -e, system tasks via/etc/cron.d/*. - anacron — catches up day/week/month tasks cron missed because the machine was off, after boot. RHEL 9 default.
- at — schedule just once. One-shot like
at now + 30 minutes. Needs atd service. - systemd timer —
.timer+.servicepair. journald integration, dependencies, resource control, absorbs anacron withPersistent=true. Put new tasks here when possible. - Debugging core: cron environment variables, separate validation with timer service standalone trigger.
Next — intro to containers #
That covers the tools for time-based task management in RHEL 9. Next we move on to containers, which let you run multiple isolated environments on a single machine.
In #7 Intro to containers — Podman/Buildah/Skopeo (differences from Docker) we cover Podman, the container standard for RHEL 9. The structure of operating without a daemon while using nearly the same commands as Docker, rootless containers, the flow of building images with Buildah, and moving between registries with Skopeo — organized in one cycle.