Configs systemd timers

systemd timers instead of cron, explained

One job, two files: a .service that says what to run and a .timer that says when. The example runs df -h / every night at 03:15 and keeps the output in the journal. Each entry says what was run on Debian 13 with systemd 257. For the commands to start, stop and inspect units, see the systemd cheat sheet.

The two files

Both go in /etc/systemd/system/ and share a name, so disk-report.timer starts disk-report.service without being told.

# /etc/systemd/system/disk-report.service
[Unit]
Description=Disk usage report

[Service]
# Run the command once and wait for it to finish
Type=oneshot
# Full path. Anything it prints goes to the journal
ExecStart=/usr/bin/df -h /
# /etc/systemd/system/disk-report.timer
[Unit]
Description=Disk usage report every day at 03:15

[Timer]
# Every day at 03:15 in the machine's time zone
OnCalendar=*-*-* 03:15:00
# If a run was missed while the timer wasn't running, run once when it starts again
Persistent=true

[Install]
# What "systemctl enable" hooks the timer into
WantedBy=timers.target

WHERE: /etc/systemd/system/, owned by root. After creating or editing either file, run sudo systemctl daemon-reload. Then enable and start the timer, not the service: sudo systemctl enable --now disk-report.timer.

The service: Type=oneshot and ExecStart

Type=oneshot is for a job that runs and exits. systemd counts the start as finished when the command exits, and the unit goes back to inactive. Give ExecStart the full path to the program. systemd only searches a short fixed list of directories (such as /usr/bin), not your PATH. It isn't run through a shell, so pipes and > redirects don't work there. In the test, ExecStart=/usr/bin/df -h / | tail -1 > /tmp/out.txt handed the | and everything after it to df as arguments, and the service failed. Put pipes in a script and run the script. You don't need to send output to a log file: in the test, df's two lines landed in the journal under the service's name.

journalctl -u disk-report.service -o short-iso
output
systemd[1]: Starting disk-report.service - Disk usage report...
df[5653]: Filesystem      Size  Used Avail Use% Mounted on
df[5653]: /dev/vda1        12G  1.1G   11G  10% /
systemd[1]: disk-report.service: Deactivated successfully.
systemd[1]: Finished disk-report.service - Disk usage report.

Trimmed: each line also starts with the time and the host name.

OnCalendar: when it runs

The format is weekday year-month-day hour:minute:second, with * for any value and the weekday optional. Shorthands like daily and hourly work too. Don't guess: systemd-analyze calendar shows how systemd reads an expression and when it fires next.

systemd-analyze calendar daily 'Mon..Fri 09:00' '*:0/15'
output
  Original form: daily
Normalized form: *-*-* 00:00:00
    Next elapse: Thu 2026-11-05 00:00:00 MST

  Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
    Next elapse: Thu 2026-11-05 09:00:00 MST

  Original form: *:0/15
Normalized form: *-*-* *:00/15:00
    Next elapse: Wed 2026-11-04 21:15:00 MST

Trimmed: each block also has the time in UTC and how long until then. daily means midnight, Mon..Fri 09:00 is weekdays at nine, and *:0/15 is every 15 minutes from the top of the hour. Add --iterations=3 to see the next three times.

Persistent=: catch up after downtime

With Persistent=true, systemd records when the timer last fired, in /var/lib/systemd/timers/stamp-disk-report.timer. When the timer starts again, at boot or by hand, and a run was due while it wasn't running, the job runs once straight away. The default is false: missed runs are skipped. In the test, two timers fired every minute, one with each setting. Both were stopped for two minutes, then started again. The Persistent=true one ran within three seconds, once rather than twice, and the other didn't run until its next scheduled minute. Only the persistent one had a stamp file.

[Install]: enable the timer, not the service

WantedBy=timers.target is what makes systemctl enable start the timer at every boot. The service has no [Install] section on purpose: the timer starts it. Enabling the service by mistake does nothing except print a long warning that starts The unit files have no installation config, and systemctl is-enabled shows it as static.

Check it: systemctl list-timers

systemctl list-timers disk-report.timer
output
NEXT                        LEFT LAST PASSED UNIT              ACTIVATES
Thu 2026-11-05 03:15:00 MST   6h -         - disk-report.timer disk-report.service

1 timers listed.

NEXT and LEFT say when it fires next, LAST and PASSED when it last did. Leave out the name to see every active timer, and add --all to include inactive ones. Test the job with sudo systemctl start disk-report.service, which runs it now. That run doesn't count as the timer firing: in the test, LAST still showed - afterwards.

Check both files before you reload. A typo in OnCalendar is not fatal at first sight: the timer loads, shows bad-setting, and refuses to start. systemd-analyze verify names the line:

systemd-analyze verify /etc/systemd/system/disk-report.service /etc/systemd/system/disk-report.timer

No output and exit status 0 means both files parsed. With OnCalendar=*-*-* 3:15:00 daily in a test timer it printed Failed to parse calendar specification, ignoring with the file name and line number, and exited with status 1.

Tested with systemd 257 (257.13) on Debian 13 (aarch64) in a throwaway VM. Related: systemd cheat sheet, all configs.