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