Skip to content

Scheduling Jobs — Cron & systemd Timers

Almost every server needs to run tasks on a schedule: backups, log rotation, certificate renewals, health checks. This page covers the traditional cron daemon and the modern systemd timer, with guidance on choosing between them.

Tested on

AlmaLinux 9.4 (cronie 1.5.7, systemd 252) and Ubuntu 22.04 (cron 3.0pl1, systemd 249).

Part 1 — Cron

Cron is a daemon that runs commands at fixed times. On AlmaLinux/RHEL the package is cronie; on Debian/Ubuntu it is cron. It is usually installed already.

# AlmaLinux / RHEL
sudo dnf install -y cronie
sudo systemctl enable --now crond

# Debian / Ubuntu
sudo apt install -y cron
sudo systemctl enable --now cron

Editing your crontab

Each user has a personal crontab. Manage it with crontab:

crontab -e        # edit your crontab (opens $EDITOR)
crontab -l        # list your crontab
crontab -r        # remove your crontab (careful — no confirmation!)
sudo crontab -e -u alice   # edit another user's crontab (as root)

The five-field schedule

A cron line has five time fields followed by the command:

┌───────────── minute        (0 - 59)
│ ┌─────────── hour          (0 - 23)
│ │ ┌───────── day of month  (1 - 31)
│ │ │ ┌─────── month         (1 - 12)
│ │ │ │ ┌───── day of week   (0 - 7, Sun = 0 or 7)
│ │ │ │ │
* * * * *  command-to-run
Field special Meaning
* every value
5 exactly that value
1,15,30 a list
9-17 a range (inclusive)
*/10 a step — every 10th value

Common examples:

# Every day at 02:30
30 2 * * *   /usr/local/bin/backup.sh

# Every 15 minutes
*/15 * * * * /usr/local/bin/disk-check.sh

# Weekdays at 08:00 (Mon=1 ... Fri=5)
0 8 * * 1-5  /usr/local/bin/report.sh

# First of every month at midnight
0 0 1 * *    /usr/local/bin/monthly.sh

# Top of every hour
0 * * * *    /usr/local/bin/hourly.sh

Cron also accepts shorthand: @reboot, @hourly, @daily, @weekly, @monthly, @yearly.

Validate before trusting

Use crontab.guru to read an unfamiliar schedule. For systemd timers there is a built-in validator — see below.

System-wide cron files

Beyond per-user crontabs, the system has its own:

  • /etc/crontab — system crontab. Lines here have an extra field: the user to run as.
  • /etc/cron.d/ — drop-in files with the same format as /etc/crontab. Best place for package- or admin-managed jobs.
# /etc/cron.d/disk-check  — note the user field (6th column)
SHELL=/bin/bash
PATH=/usr/sbin:/usr/bin:/sbin:/bin

*/15 * * * *  root  /usr/local/bin/disk-check.sh 90

The cron.daily / weekly / monthly directories

Drop an executable script (no schedule line, just a script) into one of these and it runs on that cadence:

/etc/cron.hourly/
/etc/cron.daily/
/etc/cron.weekly/
/etc/cron.monthly/
sudo install -m 0755 /dev/stdin /etc/cron.daily/cleanup <<'EOF'
#!/usr/bin/env bash
find /tmp -type f -mtime +7 -delete
EOF

These are run by /etc/crontab (via run-parts) or by anacron.

Environment and PATH gotchas

This is the number-one cause of "works in my shell, fails in cron":

  • Cron runs with a minimal environment — a short PATH (often /usr/bin:/bin) and no profile sourced.
  • cd, aliases, and shell functions from your .bashrc are not available.

Defensive practices:

# In the crontab, set a sane PATH at the top:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# In the script, use absolute paths and a known shell:
#!/usr/bin/env bash
/usr/bin/rsync -a /data/ /backup/data/

Capturing output and logging

By default cron emails any output to the job owner via local mail — which on most servers goes nowhere. Redirect explicitly:

# Append stdout AND stderr to a log file
0 2 * * *  /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# Discard stdout, keep errors in the log
*/5 * * * * /usr/local/bin/probe.sh  >/dev/null 2>> /var/log/probe.err

You can also watch cron's own activity in the journal:

journalctl -u crond --since today          # AlmaLinux/RHEL
journalctl -u cron  --since today          # Debian/Ubuntu

anacron — for machines that aren't always on

Cron assumes the machine is running at the scheduled time; a job missed while the server was off is simply skipped. anacron instead tracks when each job last ran and catches up after boot. It works in day-granularity and powers /etc/cron.daily and friends on many systems, configured via /etc/anacrontab:

# period(days)  delay(min)  job-id        command
1               5           cron.daily    run-parts /etc/cron.daily
7               25          cron.weekly   run-parts /etc/cron.weekly
@monthly        45          cron.monthly  run-parts /etc/cron.monthly

This is ideal for laptops and workstations that are not on 24/7.

Part 2 — systemd timers

systemd timers are the modern alternative. A timer is a unit that activates a matching service on a schedule. They integrate with the journal, support catch-up runs, randomized delays, and dependencies. See the systemd service management page for service-unit basics.

A timer is two units that share a name:

  1. A .service of type oneshot that does the work.
  2. A .timer that says when to start it.

Step 1 — the service unit

# /etc/systemd/system/disk-check.service
[Unit]
Description=Check disk usage and warn if over threshold

[Service]
Type=oneshot
ExecStart=/usr/local/bin/disk-check.sh 90

A oneshot service runs to completion and exits — exactly right for a scheduled job. No [Install] section is needed; the timer starts it.

Step 2 — the timer unit

# /etc/systemd/system/disk-check.timer
[Unit]
Description=Run disk-check every 15 minutes

[Timer]
OnCalendar=*:0/15              # every 15 minutes, on the quarter hour
Persistent=true                # run a missed job after boot (like anacron)
RandomizedDelaySec=120         # spread load: start up to 120s late, at random
Unit=disk-check.service        # optional; inferred from matching name

[Install]
WantedBy=timers.target
Directive Purpose
OnCalendar= When to fire, in systemd calendar syntax
Persistent=true If the trigger was missed (machine off), run as soon as possible
RandomizedDelaySec= Jitter to avoid thundering-herd across many hosts
OnBootSec= Run N seconds after boot (relative timer)
OnUnitActiveSec= Run N after the unit last ran (relative timer)

OnCalendar syntax

The format is DayOfWeek Year-Month-Day Hour:Minute:Second:

OnCalendar=*-*-* 02:30:00      # daily at 02:30
OnCalendar=Mon-Fri 08:00       # weekdays at 08:00
OnCalendar=*:0/15              # every 15 minutes
OnCalendar=*-*-01 00:00:00     # first of every month
OnCalendar=hourly              # shorthand
OnCalendar=daily               # shorthand (midnight)

Step 3 — enable and start

# Reload after writing/editing unit files
sudo systemctl daemon-reload

# Enable (start on boot) and start the TIMER, not the service
sudo systemctl enable --now disk-check.timer

Inspecting timers

# List all active timers, with last and next run times
systemctl list-timers

# Status of a specific timer and its service
systemctl status disk-check.timer
systemctl status disk-check.service

# View the job's output from the journal
journalctl -u disk-check.service --since today
$ systemctl list-timers
NEXT                        LEFT       LAST                        PASSED   UNIT               ACTIVATES
Sun 2026-06-07 11:45:00 IST 3min left  Sun 2026-06-07 11:30:00 IST 11min ago disk-check.timer  disk-check.service

Validate a calendar expression

systemd ships a validator — use it before deploying:

systemd-analyze calendar "Mon-Fri 08:00"
  Original form: Mon-Fri 08:00
Normalized form: Mon..Fri *-*-* 08:00:00
    Next elapse: Mon 2026-06-08 08:00:00 IST
       (in UTC): Mon 2026-06-08 02:30:00 UTC
       From now: 20h left

You can also test how a job behaves manually:

sudo systemctl start disk-check.service     # run it now, on demand

Cron vs systemd timers — which to use?

Consideration cron systemd timer
Setup effort One line — very quick Two unit files
Logging DIY redirection to a file Automatic, in the journal
Missed-run catch-up Only via anacron (daily+) Persistent=true (any schedule)
Randomized jitter No RandomizedDelaySec=
Dependencies / ordering None Full unit dependency model
Resource limits, sandboxing None Inherits service [Service] options
Portability / familiarity Universal, everyone knows it systemd-only

Rule of thumb

For a quick one-off or a script you'll never touch again, cron is fine. For anything you operate long-term — needs logging, catch-up, jitter, or resource control — prefer systemd timers.

Verify your work

# Cron: confirm the daemon is running and your job is listed
systemctl is-active crond        # (or 'cron' on Debian/Ubuntu)
crontab -l

# Timer: confirm it's enabled and scheduled
systemctl is-enabled disk-check.timer        # -> enabled
systemctl list-timers --all | grep disk-check

# Validate the schedule and do a manual run
systemd-analyze calendar "*:0/15"
sudo systemctl start disk-check.service
journalctl -u disk-check.service -n 20

Summary

  • cron: edit with crontab -e; five fields min hour dom mon dow; system jobs in /etc/crontab and /etc/cron.d/; drop scripts in cron.daily/weekly/monthly.
  • Watch for cron's minimal PATH/environment — use absolute paths and redirect output to a log.
  • anacron catches up missed daily/weekly/monthly jobs on machines that aren't always on.
  • systemd timers: a oneshot .service plus a .timer with OnCalendar, Persistent=true, and RandomizedDelaySec; enable the .timer, inspect with systemctl list-timers, validate with systemd-analyze calendar.
  • Use cron for quick jobs, timers for anything needing logging, catch-up, jitter, or dependencies.

Related: systemd service management · Bash scripting.

Test yourself