systemd Timers: A Complete Guide to Scheduling With OnCalendar
Contents
A systemd timer is a .timer unit that starts a .service unit on a schedule: OnCalendar= for calendar times such as Mon..Fri 09:00, or monotonic settings such as OnBootSec= and OnUnitActiveSec= for intervals. You write both files, run systemctl enable --now name.timer, and check the next run with systemctl list-timers.
Behavior here is from systemd 262.1, the current release, checked October 24, 2026 against its man pages and source. Debian 13 packages 257, Ubuntu 24.04 packages 255, and Debian 12 and CentOS Stream 9 (upstream of RHEL 9) package 252 (Debian, Ubuntu, CentOS Stream); systemctl --version shows yours. Next to a crontab line, a timer costs a second file and gets you the journal, catch-up after downtime, no overlapping runs, and seconds and time zones in the schedule. systemd timers vs cron covers the trade-offs and migrating a crontab.
A Timer Starts the Service With the Same Name#
backup.timer starts backup.service unless Unit= names another unit (systemd.timer(5)):
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup.service every night at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
sudo systemctl start backup.service # run the job once now to test itType=oneshot suits a job that runs and exits: the unit stays activating until the process exits, then shows inactive (dead), and systemctl start waits for the job and fails if it fails (systemd.service(5)). The service needs no [Install] section, because the timer starts it. WantedBy=timers.target starts the timer at boot (systemd.special(7)), and --now starts it immediately, which enable alone doesn't (systemctl(1)).
OnCalendar Syntax With Examples#
A calendar expression reads DayOfWeek Year-Month-Day Hour:Minute:Second TimeZone. Each field can be *, a list (1,15), a range (Mon..Fri, 9..17), or a value with a repetition (0/15 is 0 and every 15 after it). Without a date, every day matches; without a time, it's midnight; without seconds, :00 (systemd.time(7)).
| Schedule | OnCalendar= |
|---|---|
| Every day at 02:30 | 02:30 |
| Weekdays at 09:00 | Mon..Fri 09:00 |
| Every 15 minutes | *:0/15 |
| First Monday of the month at 09:00 | Mon *-*-01..07 09:00 |
| Last day of the month at 23:00 | *-*~01 23:00 |
| Weekdays at 09:00 in New York | Mon..Fri 09:00 America/New_York |
| Every 30 seconds | *:*:0/30 |
When an expression names a weekday and a date, both must match, so Mon *-*-01..07 is the one Monday among the first seven days. ~ counts back from the month's end, so ~01 is the last day; it needs systemd 233, and a zone other than UTC or local time needs 235. Shorthands include hourly, daily (midnight), weekly (Monday 00:00), and monthly; a timer with several OnCalendar= lines fires on each.
Without a zone, the expression uses the system zone. In a zone with daylight saving, a time in the skipped hour doesn't run that day, and interval expressions skip the repeated hour's second pass, as cron daylight saving time shows from systemd's source.
Check an expression before installing it (--iterations needs systemd 242, systemd-analyze(1)):
systemd-analyze calendar --iterations=3 'Mon *-*-01..07 09:00'
systemctl list-timers --allThe first prints the normalized form and the next three run times, with an (in UTC) line for each when the host isn't on UTC; an expression that can't match, such as *-04-31, shows Next elapse: never. The second lists every loaded timer with its next and last run, inactive ones included.
Monotonic Timers Run at Intervals#
| Setting | Counts from |
|---|---|
OnActiveSec= |
When the timer was started |
OnBootSec= |
Boot |
OnStartupSec= |
When the service manager started; for user timers, login |
OnUnitActiveSec= |
When the service last started |
OnUnitInactiveSec= |
When the service last finished |
These are "independent of wall-clock time and timezones", so clock changes and daylight saving don't move them, and they pause during suspend unless WakeSystem= is set (systemd.timer(5)).
Use them when the gap matters more than the clock: OnUnitInactiveSec=1h waits an hour after each run ends, so a slow run pushes the next one back. Pair it, or OnUnitActiveSec=, with OnBootSec=: both count from the service's last run and do nothing until a first run exists (timer.c), while OnBootSec= and OnStartupSec= fire at once if their time passed before the timer started. OnActiveSec= suits one-off delays, as with systemd-run --on-active below.
[Timer]
OnBootSec=10min
OnUnitInactiveSec=1hTimer Options That Change When the Job Runs#
All of these go in [Timer]; versions are from systemd.timer(5).
| Option | Default | Effect | Since |
|---|---|---|---|
Persistent= |
false |
Runs a missed OnCalendar= event once when the timer starts again |
212 |
AccuracySec= |
1min |
Window after the scheduled time in which systemd may fire | 209 |
RandomizedDelaySec= |
0 |
Random delay, picked again before each run | 229 |
FixedRandomDelay= |
false |
Keeps that delay stable per machine, user, and timer | 247 |
RandomizedOffsetSec= |
0 |
Stable offset that keeps the schedule's rhythm across reboots | 258 |
WakeSystem= |
false |
Resumes the machine from suspend to run the job | 212 |
RemainAfterElapse= |
true |
Keeps a timer that can't fire again loaded and queryable | 229 |
DeferReactivation= |
false |
After a run overruns its next slot, waits for the following slot | 257 |
Persistent=true stores the last trigger time as the modification time of /var/lib/systemd/timers/stamp-backup.timer, or under ~/.local/share/systemd/timers/ for user timers. When the timer starts again, at boot or after a stop, systemd runs the service once if any OnCalendar= event fell in the gap. The stamp is written when the timer fires, not when the job ends, so a run cut short by a shutdown isn't repeated (timer.c). Before deleting a timer, run systemctl clean --what=state backup.timer (systemd 243) to remove the stamp.
With the default AccuracySec=1min, systemd fires at a point in the minute after the scheduled time that is stable for the host and shared by its timers, to batch wake-ups. A 02:30 job can start at 02:30:47, and a 30-second timer runs about once a minute (sd-event.c). Set AccuracySec=1s when start time matters. RandomizedDelaySec= does the opposite, spreading starts out, and the man page pairs it with AccuracySec=1us.
If the service is still running when the timer elapses, "it is not restarted, but simply left running", so a timer never overlaps its own job, which cron needs a lock for (preventing overlapping cron jobs). The missed slot runs as soon as the long run finishes, unless DeferReactivation=yes. Older systemd warns about an unknown option and ignores it, so DeferReactivation= does nothing on Ubuntu 24.04.
Service Settings for Scheduled Jobs#
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=backup
WorkingDirectory=/var/lib/backup
Environment=BACKUP_RETENTION_DAYS=14
EnvironmentFile=/etc/backup/backup.env
ExecStart=/usr/local/bin/backup.sh
TimeoutStartSec=2h
Nice=10
CPUQuota=50%
MemoryMax=2GUser=,WorkingDirectory=: system services otherwise run as root in/(systemd.exec(5)).Environment=,EnvironmentFile=: variables in the unit, orKEY=valuelines read from a file; a missing file fails the start unless the path starts with-. systemd.exec(5) warns that environment variables "are not suitable for passing secrets" and offersLoadCredential=.TimeoutStartSec=: a oneshot service has no time limit by default. A hung run staysactivating, and because the timer won't start a second copy, every later run is skipped. With a limit, systemd kills the run and fails it with resulttimeout.RuntimeMaxSec=doesn't apply to oneshot services (systemd.service(5)).Nice=,CPUQuota=,MemoryMax=:CPUQuota=50%is half of one CPU, and pastMemoryMax=the OOM killer acts inside the unit (systemd.resource-control(5)).network-online.target: aPersistent=catch-up can start early in boot, so a job that needs the network should list it inWants=andAfter=.
Restart=on-failure is allowed on a oneshot, but RestartSec= defaults to 100 ms and starts are limited to 5 in 10 seconds (systemd-system.conf(5)), so a job that fails at once is retried four times in under a second, then left failed with result start-limit-hit. In systemd 262 each failed attempt also triggers OnFailure=, unless RestartMode=direct (254) is set (service.c). Raise RestartSec= to minutes, or retry inside the script.
User Timers and Lingering#
User timers use the same files in ~/.config/systemd/user/ with systemctl --user. They run as you, so leave out User=; timers.target exists in the user manager too.
mkdir -p ~/.config/systemd/user
# Put report.service and report.timer there, then:
systemctl --user enable --now report.timer
systemctl --user list-timers
journalctl --user-unit=report.service
loginctl enable-linger "$USER"Without lingering, your user manager, and so your timers, run only from your first login until your last session ends. loginctl enable-linger starts it at boot and keeps it after logout (loginctl(1)). systemd's default polkit policy lets you enable lingering for yourself; another user's needs admin rights (policy). loginctl show-user "$USER" --property=Linger confirms it.
Transient Timers With systemd-run#
# Run prune-cache every day at 03:00, until the next reboot.
sudo systemd-run --unit=prune-cache --on-calendar='*-*-* 03:00:00' \
--property=User=app /usr/local/bin/prune-cache
# Run a command once, two hours from now.
sudo systemd-run --on-active=2h /usr/local/bin/rotate-keyssystemd-run creates a timer and a service, here prune-cache.timer and prune-cache.service, and starts only the timer; --property= sets service options and --timer-property= timer options (systemd-run(1)). Transient units live in /run/systemd/transient/ (systemd.unit(5)), so a reboot removes them, and systemd-run sets RemainAfterElapse=no so a finished one-time timer unloads itself (run.c).
Logs and Failure Alerts#
Job output goes to the journal by default:
systemctl status backup.timer # "Trigger:" shows the next run
systemctl status backup.service # last run's result and recent log lines
journalctl -u backup.service --since yesterday
journalctl -u backup.service -I # latest run only (systemd 257)OnFailure= starts another unit when this one enters the failed state (systemd.unit(5)). systemd.exec(5) recommends a template handler named with %n, the failing unit's full name:
# /etc/systemd/system/[email protected]
[Unit]
Description=Report the failure of %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i#!/bin/sh
# /usr/local/bin/notify-failure: mail root the failed unit's status and last log lines.
unit=$1
systemctl status --full --no-pager --lines=30 "$unit" 2>&1 \
| mail -s "$(hostname): $unit failed (${MONITOR_SERVICE_RESULT:-unknown})" rootAdd OnFailure=notify-failure@%n.service to the [Unit] section of backup.service. A failure then starts [email protected], whose %i is backup.service. Because each failing unit gets its own instance, systemd 251 and later also give the handler $MONITOR_SERVICE_RESULT, $MONITOR_EXIT_STATUS, and $MONITOR_UNIT. mail needs a working mail transport.
ExecStopPost= is the in-unit alternative: it runs after every run, successful or not, with the result in $SERVICE_RESULT (success, exit-code, timeout, signal, oom-kill, and others) and the main process's $EXIT_CODE and $EXIT_STATUS, available since systemd 232 (systemd.exec(5)). Both hooks run only when the service runs, so a timer that never started or a host that's down produces nothing to react to.
Why a systemd Timer Isn't Running#
systemctl list-timers --all backup.timer
systemctl status backup.timer backup.service- Enabled but never started.
enablewithout--nowstarts the timer at the next boot; until then it'sinactive (dead)withTrigger: n/a. Starting the service runs the job once but doesn't start the timer. - Edited without a reload. systemd keeps the version it loaded and systemctl warns that the unit "changed on disk" (systemctl-util.c). After
systemctl daemon-reload, a waiting timer recomputes its next run (timer.c). OnCalendar=didn't parse. systemd logsFailed to parse calendar specification, ignoringand drops the line (load-fragment.c). With no trigger left, it refuses the timer (timer.c), andsystemctl startreports "a bad unit file setting".systemd-analyze verifyon the file shows the error first.- It parsed, but means something else.
*:15is quarter past every hour, not every 15 minutes (*:0/15), andMon..Fri *-*-01runs on the 1st only when it's a weekday. - The wrong zone. Without a zone,
OnCalendar=follows the system zonetimedatectlreports, so a time meant as UTC runs in local time. - The minute of slack. The default
AccuracySec=can start a job up to a minute late. - The last run is still going.
systemctl statusshows the serviceactivating (start)since the hang began. SetTimeoutStartSec=. - The machine was asleep or off. Events that pass during suspend run once on resume; events that pass while it's off run only with
Persistent=true. - The service name doesn't match. The timer logs "Refusing to start, unit backup.service to trigger not loaded" (unit.c).
Monitoring systemd Timers With CronRadar#
Most of those causes end with a job that doesn't run and a host that doesn't say so. A dead man's switch catches that: the job checks in, and an outside monitor alerts when a check-in is late. There's no CronRadar SDK for systemd and no auto-discovery of timers, so each job reports with its own HTTP check-in.
ExecStartPre= and ExecStopPost= run around every invocation, so one script can report for any service:
#!/bin/sh
# /usr/local/bin/cronradar-report: report a systemd service run to CronRadar.
# "start" runs from ExecStartPre=, "finish" from ExecStopPost=.
url="https://cron.life/api/v1/ping/$CRONRADAR_MONITOR"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$CRONRADAR_SCHEDULE" \
--data-urlencode "gracePeriod=$CRONRADAR_GRACE" \
--data-urlencode "run_id=$INVOCATION_ID" \
"$@" "$url/$event" || true
}
case $1 in
start) report start ;;
finish)
if [ "$SERVICE_RESULT" = success ]; then
report complete
else
report fail --data-urlencode \
"message=systemd result $SERVICE_RESULT (${EXIT_CODE:-no exit code} ${EXIT_STATUS:-})"
fi
;;
esac
exit 0Attach it with a drop-in, leaving the job's unit file untouched:
# /etc/systemd/system/backup.service.d/cronradar.conf
[Service]
Environment=CRONRADAR_MONITOR=nightly-backup
Environment="CRONRADAR_SCHEDULE=30 2 * * *"
Environment=CRONRADAR_GRACE=900
EnvironmentFile=/etc/cronradar.env
ExecStartPre=-/usr/local/bin/cronradar-report start
ExecStopPost=-/usr/local/bin/cronradar-report finishsudo install -m 0755 cronradar-report /usr/local/bin/cronradar-report
sudo install -m 0600 /dev/null /etc/cronradar.env
sudoedit /etc/cronradar.env # one line: CRONRADAR_API_KEY=ck_app_12345_abcdef
sudo systemctl daemon-reload- The key: the service manager reads
/etc/cronradar.envbefore starting the job's processes, so the file stays readable only by root while the job runs asbackup, though the job's processes can read the variable. Use one CronRadar application and key per environment. - Failures can't spread:
-makes systemd ignore a command's failure; without it, a failing stop-post command turns a successful run into a failed one (service.c). The script also ignores curl errors, and--max-time 5caps each report at five seconds. - One run, one ID:
$INVOCATION_IDis new for each run and shared by its processes, so start and finish carry the samerun_id, the IDjournalctl --invocation=takes (journalctl(1), systemd 257). - Results:
successreportscomplete; every other result, includingtimeoutandoom-kill, reportsfail, which alerts immediately. WithRestart=on-failure, each attempt is its own run, so the first failed attempt alerts. Each report carries the schedule, so the first run creates the monitor and a deleted one is recreated by the next. - Hangs: with no
TimeoutStartSec=, a hung run leavesstartas the last report and blocks the timer, so the monitor goescriticalas stuck once the next scheduled time plus the grace period passes. With the timeout,failarrives when systemd kills the run.
Converting OnCalendar to a CronRadar Schedule#
CronRadar takes standard five-field cron and evaluates it in UTC. On a host that isn't on UTC, convert from the (in UTC) times systemd-analyze calendar prints.
OnCalendar= (UTC host) |
CronRadar schedule |
|---|---|
*-*-* 02:30:00 |
30 2 * * * |
Mon..Fri 09:00 |
0 9 * * 1-5 |
*:0/15 |
*/15 * * * * |
weekly |
0 0 * * 1 |
Mon *-*-01..07 09:00 |
0 9 1-7 * 1 |
Mon..Fri 09:00 America/New_York |
0 13 * * 1-5, gracePeriod of 3600 plus the run time |
The first-Monday row works because CronRadar, like systemd, expects a run only on dates that match both day fields. The New York row follows the schedule syntax rule for zones with daylight saving: the earlier of the two UTC times (13:00 in summer, 14:00 in winter) and an extra hour of grace. RandomizedDelaySec= and AccuracySec= delay the start, so add them to the grace period too.
Some timers have no five-field equivalent:
- Seconds, such as
*:*:0/30: report at most once a minute with* * * * *, as in running a job every 30 seconds. - Last day of the month: run the timer on
*-*-28..31 23:00, begin the job's script with[ "$(date -d tomorrow +%d)" = 01 ] || exit 0(GNU date), and use0 23 28-31 * *. The early exits reportcomplete, so every expected run arrives. - Several
OnCalendar=lines no single expression covers, such asMon 09:00andFri 17:30: give each its own timer, service, and monitor. - Monotonic timers drift against the clock. Use the cron step of the same length, such as
0 * * * *forOnUnitActiveSec=1h, with a grace period of at least one interval.
Frequently Asked Questions#
How do I create a systemd timer?#
Write a service unit that runs the job, usually Type=oneshot, and a timer unit with the same name that sets OnCalendar= or a monotonic setting under [Timer] and WantedBy=timers.target under [Install]. Put both in /etc/systemd/system and run systemctl enable --now name.timer.
What does Persistent=true do in a systemd timer?#
It records when the timer last fired in a stamp file. When the timer starts again, usually at boot, systemd runs the service once if any OnCalendar= event passed while the timer was stopped or the machine was off.
How do I list systemd timers?#
systemctl list-timers shows active timers with their next and last run, and --all adds inactive ones. A user's own timers appear only in systemctl --user list-timers, run as that user.
Why is my systemd timer not running?#
Check systemctl list-timers --all and systemctl status for the timer and its service. Usual causes are a timer enabled but never started, an edit without systemctl daemon-reload, an OnCalendar= value that didn't parse or means something else, the wrong time zone, and a previous run still going. A check-in monitor such as CronRadar alerts when an expected run doesn't arrive.
How do I run a systemd timer as a user without logging in?#
Put the units in ~/.config/systemd/user, enable the timer with systemctl --user enable --now, and run loginctl enable-linger for that user. Lingering starts the user's service manager at boot and keeps it after logout.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.