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

Take this course offline

Get every free book, course edition, and software download.

Get the download library →