RHEL Intermediate #6: Job Scheduling — cron, systemd timer, at

12 min read

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:

The four tools in one line #

ToolUseTime machine was offNotes
cronrepeated tasks (every minute/hour/day/…)just skippedmost traditional, simplest
anacronday/week/month execution guaranteed even if machine was off and back oncatches up missed jobsessential for laptops / home servers
atone-shot scheduling (run once an hour later)runs immediately after machine is onone-time
systemd timerrepetition + dependencies + log integrationcatches up with Persistent=truemodern 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.

check
$ 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:

where cron reads
/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:

crontab examples
# 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.sh

User crontab #

Each user can register their own tasks.

user crontab
# register/edit (opens with editor)
$ crontab -e

# view
$ crontab -l

# clear
$ crontab -r

# another user's (root only)
$ sudo crontab -u alice -l

Why 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.

/etc/cron.d/myjob
# minute hour day month dow  user    command
*/10 * * * *                 root    /usr/local/bin/sync.sh

Operational recommendation:

  • user task → crontab -e
  • package/system task → /etc/cron.d/<name> (drop-in file)
  • avoid editing /etc/crontab directly (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.

safe pattern
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.sh

If 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.

log redirect
15 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Permission control #

/etc/cron.allow and /etc/cron.deny control per-user cron usage.

  • if cron.allow exists → only users listed there can use it
  • if cron.allow doesn’t exist and cron.deny exists → 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.

/etc/anacrontab
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.monthly

How 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.

at basic usage
# 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 3

at 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 — run when load is low
$ 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.target used 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 anacron
  • OnCalendar= — 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).

/etc/systemd/system/backup.service
[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
/etc/systemd/system/backup.timer
[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.target

For activation, only enable the timer (the timer triggers the service).

activation
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now backup.timer
$ systemctl list-timers --all

OnCalendar syntax #

Compared to cron’s * * * * *, systemd is much clearer.

OnCalendar examples
# 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 / yearly

Syntax validation and next execution time preview:

validation
$ 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 left

Getting 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.

TriggerMeaning
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=true

Persistent=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.

user timer
$ 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-timers

For tasks to keep running after the user session ends, enable lingering with loginctl enable-linger <user>.

Commonly seen system timers #

currently active timer list
$ 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? #

Criterioncron favorabletimer 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 #

cron debugging checklist
# 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 command

When systemd timer tasks don’t run #

timer debugging checklist
# 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.timer

A 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. OnCalendar has explicit DST transition handling, making it safer than cron in those environments.
  • if you enable Persistent=true on 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 #

TaskCommand
edit user crontabcrontab -e
view user crontabcrontab -l
view all timerssystemctl list-timers --all
check timer’s next execution timesystemd-analyze calendar '<expr>'
view at queueatq
cancel at taskatrm <id>
view cron logsjournalctl -u crond
timer’s paired service logjournalctl -u <name>.service
manual triggersystemctl 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 + .service pair. journald integration, dependencies, resource control, absorbs anacron with Persistent=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.

X