Timers and operations
Create a service and timer
Run scheduled server work as a paired oneshot service and timer with separate logs and state.
A systemd timer is a unit that activates another unit on a schedule. Usually that other unit is a service with the same base name. This is systemd’s answer to cron.
The split into two files is the whole point. The service says what to run. The timer says when. You can run the service by hand any time, and you can change the schedule without touching the command.
Compared to a crontab line, you also get the job’s output in the journal, real dependencies, resource limits, and catch-up behavior for machines that were off.
The service half
Put the command in a Type=oneshot service:
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly PostgreSQL backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-postgres.sh
Notice there is no [Install] section. The service is never enabled on its own. The timer owns the schedule.
You can still run it manually with sudo systemctl start backup.service. That is handy when you want a backup right now, before a risky migration.
The timer half
The schedule goes in a timer, using OnCalendar=:
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup.service every night
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
*-*-* 03:00:00 means every day at 3 AM. The format is year-month-day, then time, with * as a wildcard. For “every N minutes after the last run” you would use a monotonic setting like OnUnitActiveSec=15min instead.
Now enable the timer, not the service:
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-08-04 03:00:00 UTC 9h left - - backup.timer backup.service
list-timers is your proof. It shows the next elapse and, after the first run, when it last fired.
The mistake everyone makes once
The classic error is enabling backup.service instead of backup.timer. Then the backup runs once at every boot and never on schedule.
list-timers shows nothing for it, and that empty result is exactly how you spot the problem. If your job is not in that list, no timer owns it.
Check the schedule expression
Calendar expressions are easy to get subtly wrong. systemd-analyze calendar parses one and shows you the next elapse:
systemd-analyze calendar "*-*-* 03:00:00"
Original form: *-*-* 03:00:00
Normalized form: *-*-* 03:00:00
Next elapse: Tue 2026-08-04 03:00:00 UTC
From now: 9h left
I run this on every new expression before writing it into a timer. Thirty seconds now saves a backup that silently runs on the wrong day.
Try this without running a real backup. Point ExecStart= at /bin/true first. Create both files, reload, enable the timer, check list-timers, and confirm the schedule with systemd-analyze calendar. Once the plumbing works, swap in the real script.
Lesson completed