Scheduling tasks with the powerful systemd timers

Published:  28/07/2025 09:51

Introduction

The service manager and remplacement for the init process called systemd is now ubiquitous in the Linux world.

The current article will focus on describing one of its many features called timers.

Timers are meant to start systemd unit files on certain time triggers, be it a periodic date and time as in every Monday at 6AM, or every X time units, or some time after the system started or multiple of these combined.

As such, it stands as a replacement or alternative for what the cron daemon already does, with a few differences.

Mainly, systemd provides a lot of isolation (security) primitives as well as many built-in helpers like running another process before the main scheduled process or checking for certain conditions.

It's also easier to query systemd to see when timers will next trigger and they can be enabled, disabled, etc. using the usual systemctl commands.

The log files are also conveniently browsable using journalctl.

Querying systemd timers

Your system probably already has a few timers if it's somewhat of a modern Linux installation.

They can be all listed using:

systemctl list-timers

Which yields a big table with pagination by default and shows the active timers only:


NEXT                         LEFT           LAST                         PASSED               UNIT                         ACTIVATES
Fri 2025-07-25 15:30:22 CEST 20min left     Mon 2025-01-13 13:27:17 CET  6 months 10 days ago apt-daily.timer              apt-daily.service
Fri 2025-07-25 19:59:58 CEST 4h 50min left  Mon 2025-01-13 13:27:17 CET  6 months 10 days ago man-db.timer                 man-db.service
Sat 2025-07-26 00:00:00 CEST 8h left        -                            -                    dpkg-db-backup.timer         dpkg-db-backup.service
Sat 2025-07-26 00:00:00 CEST 8h left        Fri 2025-07-25 09:36:34 CEST 5h 33min ago         logrotate.timer              logrotate.service
Sat 2025-07-26 06:57:03 CEST 15h left       Fri 2025-07-25 09:42:08 CEST 5h 27min ago         apt-daily-upgrade.timer      apt-daily-upgrade.service
Sat 2025-07-26 09:57:43 CEST 18h left       Fri 2025-07-25 09:57:43 CEST 5h 11min ago         systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service
Sun 2025-07-27 03:10:50 CEST 1 day 12h left Fri 2025-07-25 09:36:48 CEST 5h 32min ago         e2scrub_all.timer            e2scrub_all.service

7 timers listed.
Pass --all to see loaded but inactive timers, too.

NB: Most of the commands presented here are meant to be ran as root unless a "user" systemd is to be the host of the timer but these are out of scope for us today.

You probably already know systemctl status, it's also useful here. Example:

~# systemctl status logrotate.timer

● logrotate.timer - Daily rotation of log files
     Loaded: loaded (/lib/systemd/system/logrotate.timer; enabled; preset: enabled)
     Active: active (waiting) since Fri 2025-07-25 09:42:28 CEST; 5h 30min ago
    Trigger: Sat 2025-07-26 00:00:00 CEST; 8h left
   Triggers: ● logrotate.service
       Docs: man:logrotate(8)
             man:logrotate.conf(5)

Jul 25 09:42:28 deleteme-test systemd[1]: Started logrotate.timer - Daily rotation of log files.

It shows when the next trigger will happen, when the timer was started, if it's "enabled" and even a few lines of the journal for that timer.

Existing timer definitions for system services will usually be in "/usr/lib/systemd/system/".

Your own definitions (to run as root) should be in "/etc/systemd/system".

Creating a systemd timer

Since timers are running systemd units, we need two files:

  • A <TIMER_NAME>.timer file with the time triggers and conditions
  • A <TIMER_NAME>.service file to be ran when the timer triggers

A .service file with the same name as the timer is automatically used when the timer triggers.

It's possible to use a different unit name by adding the Unit directive to the timer file but this isn't recommended for reasons of clarity.

To make things easier, the service is always of Type=oneshot as being expected to naturally end at some point, and uses ExecStart to start some script or program.

Let's create an example timer calling some dummy script to perform some dummy cleanup task.

Starting with the cleanup.timer file:

[Unit]
Description=Runs the cleanup script every day at 12AM

[Timer]
OnCalendar=*-*-* 12:00:00

[Install]
WantedBy=timers.target

And now example.service:

[Unit]
Description=Clean some things up

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cleanup

The timer is ready but it's not running. We can start it like you would a systemd service except we're using the .timer extension:

systemctl start cleanup.timer

However the timer won't be started automatically at boot, for that we have to "enable" it (this is also normal systemd business):

systemctl enable cleanup.timer

NB: Systemd loads its config in memory. It will pick up the new files when originally created but if you make any modification to existing timer or service files you have to reload the config and restart the timer:

systemctl daemon-reload && systemctl restart cleanup.timer

We can now use journalctl with the service unit name to check how it went:

~# journalctl -u cleanup.service -f

Jul 25 12:00:00 deleteme-test systemd[1]: Starting cleanup.service - Clean some things up...
Jul 25 12:00:00 deleteme-test cleanup[386]: 2025-07-25 12:00:00 - Clean-up job started
Jul 25 12:00:03 deleteme-test cleanup[390]: 2025-07-25 12:00:03 - Done
Jul 25 12:00:03 deleteme-test systemd[1]: cleanup.service: Deactivated successfully

The output is just something sent to standard output from my dummy script:

#!/bin/bash

date +"%Y-%m-%d %H:%M:%S - Clean-up job started"
sleep 3
date +"%Y-%m-%d %H:%M:%S - Done"

You'll notice the extra timestamps added for logging purpose are not needed since the journal has timestamps as a feature.

Timer trigger types

We have just seen OnCalendar, the most common trigger closely ressembling the cron schedule templates.

One of the differences between timers and cron is how systemd won't run a triggered service if it's already running, and that's due to how systemd works in general.

Attempting to start a systemd service that is already running or starting won't do anything.

It's obviously possible to implement a logic to check whether a cron task is already running or not but systemd timers do it by design making it a very good choice for long running tasks.

OnCalendar

The basic full pattern is:

day year-month-day hours:minutes:seconds

Where:

  • The day is actually the "day of the week" (as in Mon, Tue, ...) and can be omitted, so can be the date if it's all asterisks as in *-*-*
  • Seconds can be omitted
  • A star means "every unit of this time", like it does with cronjobs
  • Two dots ("..") define a range; for instance 12..15 in the hours placeholder means it triggers at 12, 13, 14 and 15h
  • A comma allows specifying multiple values for a placeholder, for instance 10,22 in the hours fields means it triggers at 10:00 and 22:00

Let's review a few examples of OnCalendar patterns.

Every monday at 6:00 (removing "Mon" makes it every day at 6:00):

OnCalendar=Mon *-*-* 6:00

Every first day of the year at midnight:

OnCalendar=*-01-01 00:00

On saturday and sunday at 20:00:

OnCalendar=Sat,Sun 20:00

Run every minutes but only from 8:00 to 12:00 every day:

OnCalendar=8..12:*:00

Run every 30 minutes, precisely at every hour:09 and hour:39 (can also be done with monotonic timers as we'll see later):

OnCalendar=*:09,39:00

We can also just use daily, weekly, monthly, hourly etc. as the value given to OnCalendar.

When the schedule gets to complicated it's allowed to have multiple OnCalendar directives.

Monotonic timers

Other timer types are meant to run X units of time after activation, system boot or some other service related event. They can be combined with OnCalendar.

There are 5 of these options: OnActiveSec, OnBootSec, OnStartupSec, OnUnitActiveSec, OnUnitInactiveSec.

We won't detail all of them in this article, but they can all be combined. It's even allowed to have to same one multiple times with different values (as with OnCalendar).

Values are seconds by default but a time unit can be added for minutes or hours or combination of both (e.g. OnBootSec=5h30m works).

Monotonic timers meant to start services when the computer starts feel redundant when we remember systemd's main role is to start things at boot and it can do that without timers.

However, timers will allow adding delays and even randomizing these delays to stagger certain operations when a system comes online. As far as we know it's the best way to add a delay before starting a service, which is something we sometimes need for customers.

Combining two specific monotonic timers will also allow us to run a service every X units of time without using OnCalendar and is often preferred for tasks needing to run often.

If we want to start cleaning up after boot, when 2 minutes have elapsed, and do it only once:

[Unit]
Description=Clean-up script 2 minutes after boot

[Timer]
OnBootSec=2m

[Install]
WantedBy=timers.target

We can add OnUnitActiveSec to make it run every X units of time, because that directive sets a timer starting from the last time the service was activated. Since it activates at boot, that timer runs after boot, and then resets itself every X units of time (5 minutes in this example):

[Unit]
Description=Clean-up script 2 minutes after boot then every 5 minutes

[Timer]
OnBootSec=2m
OnUnitActiveSec=5m

[Install]
WantedBy=timers.target

When starting costly services we might want to stagger the service activation using RandomizedDelaySec.

For instance, back to running clean-up at boot but with a randomized added delay of 2 minutes:

[Unit]
Description=Clean-up script running at boot

[Timer]
RandomizedDelaySec=2m
OnBootSec=0

[Install]
WantedBy=timers.target

We'll see later that randomized delays also make sense when Persistent is involved, but more on that later.

Extra options and recipes

There's a lot to talk about that is not strictly related to timers but has more to do with systemd in general.

Persistent

Adding the Persistent=true option to timer definitions causes the last timer activation to be saved to disk. If the timer is inactive or the system is offline and the timer would have triggered during that time, it will trigger immediately the next time the timer is activated (usually the next time the system comes online).

The option only works with OnCalendar triggers and makes sense of operations that need not be done very often.

For example, imagine a big automatic upgrade that triggers every Sunday at 12:00.

If the computer was offline at that time when it should have upgraded, it will just run that upgrade the next time it comes online.

Due to the possibility of multiple operations stacking up to be ran at the next boot when a system is offline for a while, Persistent=true is often combined with RandomizedDelaySec.

Here's an example seen in Debian for automatic daily upgrades — We can see they use the randomized extra delay:

[Unit]
Description=Daily apt upgrade and clean activities
After=apt-daily.timer

[Timer]
OnCalendar=*-*-* 6:00
RandomizedDelaySec=60m
Persistent=true

[Install]
WantedBy=timers.target

Running the service when the network is up

This one is unrelated to timers but it's often required for .service files to only run after the network is up, and this can be done by adding an After condition:

[Unit]
Description=Run some script at boot
After=network.target network-online.target systemd-networkd.service

[Service]
Type=oneshot
ExecStart=/usr/bin/my_script

Do something in case of failure

When a cronjob outputs to standard error or exits with an error code, an email is automatically sent to the linux user starting the program or to the email address set in the MAILTO variable.

There isn't any built-in way for systemd to send email alerts.

We can however use OnFailure in the [Unit] part of the .service definition and implement something custom to send alerts.

OnFailure requires the name of a systemd unit, you can't directly reference a script.

We probably don't want to create one service file per alert type, so now is a good time for some systemd's service templates; We won't explain how these work in detail. In essence, service templates allow starting many services based on the same .service file by adding a unique argument to each service instance.

First thing is to create the script that will send the alert (e.g. by email) then create a service template (in "/etc/systemd/system"), for instance send-email-alert@.service.

The "@" character is what makes the unit a template so it has to be there.

Example content:

[Unit]
Description=Send email notifications for service %i
Wants=network.target

[Service]
ExecStart=/usr/local/bin/send-email-alert %i
Type=oneshot

The "%i" placeholder is the "argument" (called "instance name") for the instance of the template — It will be given to OnFailure in the .service file of the timer.

We're going to use the unit name for which we want to send alerts ("cleanup" in our example) as that argument but you could also use fixed text

We can now add OnFailure to our timer .service file:

[Unit]
Description=Clean-up script 2 minutes after boot
OnFailure=send-mail-alert@%N.service

[Timer]
OnBootSec=2m

[Install]
WantedBy=timers.target

Where %N is the name of the current unit without the extension (it's "cleanup" in our case). As mentioned earlier, we could have fixed text here instead, the alert sending script will use it to identify which service has failed and receive it as its first command line argument.

Here's an example send-email-alert in shell script which doesn't actually send an email but you see how it would go:

#!/bin/bash

set -e

UNIT="$1"
HOST=$(hostname)
status="$(systemctl status "$UNIT")"
DATE=$(date)

# Here we'd actually send the email:
echo "Alert on $HOST for service $UNIT at $DATE"
echo "$status"

We found a Github repository with pretty much the same idea and maybe a different way to explain it.

Do note that OnFailure fires on non-zero return codes from the program started by the .service unit.

It doesn't care whether something got sent to standard error output or notn only the return code counts which is different than how cron behaves.

Security hardening

We're talking about security because one of the main differences between systemd timers and cron is all the options available in systemd's services (unit) files.

Evidently, these aren't all related to security, of course, but systemd offers a lot of options for resources management (the Nice option alone can be useful for scheduled tasks) and isolation to various levels.

Here are some security options to be put into the [Service] section of the timer related .service file and affect the program executed by that service directly:

  • PrivateTmp=yes — Mounts a unique tmpfs instead of /tmp
  • ProtectSystem=full — Paths /usr, /boot, /efi and /etc are strictly read-only for the process
  • ProtectHome=yes — /home is completely unavailable for the process; It's possible to also use read-only or tmpfs which created a temporary volume instead of /home
  • PrivateNetwork=yes — The service has no network access at all
  • ProtectKernelModules=true — The service cannot alter kernel modules
  • ProtectClock=true — The service cannot modify the system time
  • ProtectHostname=true — The service cannot modify the hostname
  • PrivateDevices=yes — The service can only access select pseudo-files from /dev, mainly /dev/null, /dev/random and urandom and /dev/zero — No other actual device can be accessed by the process

Some of these allow multiple options and we can't detail them all — Check out the official documentation would you want to know more.

These also transfer for general use with systemd services and are a good introduction so systemd sandboxing.

We'll probably write a future article about that someday.

Comments

Loading...